From c4c5e1d8b747263492747ac8f2c7e81ffd172eff Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 16:28:49 +0800 Subject: [PATCH 01/11] emrg: a task record names its agent with config.agent, migrated in place The task table's key was spelled project in six places that had to agree - the seeded record, the resolution, the idle-status report, task_create/task_update, the daemon's wire field and the GUI form payload - and each was a hand-written literal, so a stale one killed scheduling without a failing test. Move the key to config.agent and let a record carry one spelling. A table still carrying config.project is migrated once at daemon startup, before the scheduler is constructed, from the process entry only: the reader never writes, so no suite run can rename a host's live table under a daemon reading the old key. Six built-in templates read the key as {{ task.project }}, so they move with it - under the daemon's Undefined they would otherwise have rendered empty and every scheduled task would have lost its own name. --- emrg/gui/main.js | 6 +- .../src/components/TaskFormDialog.test.tsx | 6 +- .../src/components/TaskFormDialog.tsx | 8 +- .../src/components/WorkspaceView.test.tsx | 2 +- .../renderer/src/lib/workspaceView.test.ts | 8 +- emrg/gui/renderer/src/lib/workspaceView.ts | 8 +- emrg/server/__main__.py | 8 + emrg/server/competition_prompt.md | 4 +- emrg/server/daemon.py | 6 +- emrg/server/evolution_prompt.md | 2 +- emrg/server/feishu_prompt.md | 4 +- emrg/server/ios_app_prompt.md | 4 +- emrg/server/journal_prompt.md | 8 +- emrg/server/open_source_prompt.md | 4 +- emrg/server/scheduler.py | 95 ++++++++--- tests/test_agents_registry_migration.py | 7 +- tests/test_competition_prompt.py | 8 +- tests/test_daemon.py | 4 +- tests/test_daemon_tool_path.py | 3 + tests/test_evolution_prompt_index_rule.py | 2 +- .../test_evolution_prompt_merge_condition.py | 2 +- tests/test_evolution_prompt_vote_body.py | 2 +- tests/test_prompt_templates.py | 6 +- tests/test_schedule_window.py | 10 +- tests/test_scheduler.py | 110 ++++++------- ..._and_llm_logs_apply_the_credential_rule.py | 2 +- tests/test_task_config_key_migration.py | 153 ++++++++++++++++++ tests/test_ws_e2e.py | 18 +-- 28 files changed, 364 insertions(+), 136 deletions(-) create mode 100644 tests/test_task_config_key_migration.py diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 46e173013..fa85733db 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -968,15 +968,15 @@ vision = false // ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ────────── ipcMain.handle("emrg:taskCreate", async (_e, payload) => { - const { name, type, project, interval, enabled, repo, description, sandbox, schedule } = payload || {}; + const { name, type, agent, interval, enabled, repo, description, sandbox, schedule } = payload || {}; if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); - if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); + if (typeof agent !== "string" || !agent.trim()) throw new Error("invalid agent"); // `schedule` 必须原样转发:这张固定字段表此前把它丢掉,于是 GUI 永远只能建 // 单一频次的任务,daemon 收了也白收(issue #2490)。只有 payload 真的带了这个 // 键才发,缺席=不声明,与 task_update 的「缺省不动该字段」一致。 const fields = { - name: name.trim(), task_type: type.trim(), project: project.trim(), + name: name.trim(), task_type: type.trim(), agent: agent.trim(), interval, enabled, repo, description, sandbox, }; if ("schedule" in (payload || {})) fields.schedule = schedule; diff --git a/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx b/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx index 720a156b2..ad38fa063 100644 --- a/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx +++ b/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx @@ -37,7 +37,7 @@ function taskWith(overrides: Partial = {}): TaskRec { type: "evolution", interval: 10800, enabled: true, - config: { project: "emrg" }, + config: { agent: "emrg" }, ...overrides, }; } @@ -91,7 +91,7 @@ describe("TaskFormDialog", () => { }); it("编辑:预填任务字段,name 只读不可改名", () => { - const task = taskWith({ type: "journal", interval: 3600, enabled: false, config: { project: "sci" } }); + const task = taskWith({ type: "journal", interval: 3600, enabled: false, config: { agent: "sci" } }); (task as TaskRec & { sandbox?: string }).sandbox = "read-only"; render( wrapper( @@ -161,7 +161,7 @@ describe("TaskFormDialog", () => { expect(payload).toEqual({ name: "t1", type: "journal", - project: "sci", + agent: "sci", interval: 300, enabled: false, repo: undefined, diff --git a/emrg/gui/renderer/src/components/TaskFormDialog.tsx b/emrg/gui/renderer/src/components/TaskFormDialog.tsx index 0bed89ea6..941d5b0d9 100644 --- a/emrg/gui/renderer/src/components/TaskFormDialog.tsx +++ b/emrg/gui/renderer/src/components/TaskFormDialog.tsx @@ -19,7 +19,7 @@ import { Dialog } from "./Dialog"; export interface TaskFormPayload { name: string; type: string; - project: string; + agent: string; interval: number; enabled: boolean; repo?: string; @@ -114,8 +114,8 @@ export function TaskFormDialog({ const curType = (task?.type && types.includes(task.type) ? task.type : types[0]) || "evolution"; if (typeRef.current) typeRef.current.value = curType; - const curProj = projects.some((p) => (p.name || p.path || "") === task?.config?.project) - ? (task?.config?.project as string) + const curProj = projects.some((p) => (p.name || p.path || "") === task?.config?.agent) + ? (task?.config?.agent as string) : (projects[0]?.name || projects[0]?.path || ""); if (projectRef.current) projectRef.current.value = curProj; if (intervalRef.current) intervalRef.current.value = String(task?.interval ?? 1800); @@ -176,7 +176,7 @@ export function TaskFormDialog({ const payload: TaskFormPayload = { name, type: typeRef.current.value, - project: projectRef.current.value, + agent: projectRef.current.value, interval, enabled: enabledRef.current?.checked ?? true, repo: repoRef.current?.value.trim() || undefined, diff --git a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx index c727706f3..06de7ad37 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx @@ -82,7 +82,7 @@ describe("WorkspaceView", () => { expect(screen.getByTestId("projects-empty")).toBeInTheDocument(); const projects = [project("emrg"), project("aitokenpool", "/scm/aitokenpool")]; - const tasks = [{ name: "emrg-task", config: { project: "emrg" } }] as TaskRec[]; + const tasks = [{ name: "emrg-task", config: { agent: "emrg" } }] as TaskRec[]; const { rerender } = setup({ projects, tasks }); expect(screen.getAllByTestId("project-row")).toHaveLength(2); // emrg 有演化任务 → ⚡ 徽标;aitokenpool 无 diff --git a/emrg/gui/renderer/src/lib/workspaceView.test.ts b/emrg/gui/renderer/src/lib/workspaceView.test.ts index d38a6f621..3a0db64d8 100644 --- a/emrg/gui/renderer/src/lib/workspaceView.test.ts +++ b/emrg/gui/renderer/src/lib/workspaceView.test.ts @@ -117,10 +117,10 @@ describe("preprocessRantMarkdown", () => { }); describe("buildEvolveProjects", () => { - it("收集 config.project 集合(含 evolution 任务)", () => { + it("收集 config.agent 集合(含 evolution 任务)", () => { const tasks: TaskRec[] = [ - { name: "a", config: { project: "emrg" } }, - { name: "b", config: { project: "aitokenpool" } }, + { name: "a", config: { agent: "emrg" } }, + { name: "b", config: { agent: "aitokenpool" } }, { name: "c", config: {} }, ]; const set = buildEvolveProjects(tasks); @@ -176,7 +176,7 @@ describe("taskStatusBadge", () => { describe("taskHint", () => { it("项目 / 间隔 / 停用 join ·", () => { - const h = taskHint({ config: { project: "emrg" }, interval: 1800 }, t); + const h = taskHint({ config: { agent: "emrg" }, interval: 1800 }, t); expect(h).toBe('emrg · app.taskInterval={"n":1800}'); expect(taskHint({ enabled: false }, t)).toBe("app.taskDisabled"); }); diff --git a/emrg/gui/renderer/src/lib/workspaceView.ts b/emrg/gui/renderer/src/lib/workspaceView.ts index a9ed05b28..e54ed4a08 100644 --- a/emrg/gui/renderer/src/lib/workspaceView.ts +++ b/emrg/gui/renderer/src/lib/workspaceView.ts @@ -44,7 +44,7 @@ export interface TaskRec { interval?: number | null; last_run_at?: string | null; saturation?: { heartbeat_active?: boolean; heartbeat_interval?: number } | null; - config?: { project?: string; repo?: string; sandbox?: string } | null; + config?: { agent?: string; repo?: string; sandbox?: string } | null; /** 分时段频次窗口;缺省=全天单一 `interval`(`list_tasks()` 逐字回报) */ schedule?: ScheduleWindow[] | null; /** 任务面板「打开会话」所需字段(daemon TaskHandler.status() 返回,vanilla #924 迁移时丢失) */ @@ -142,11 +142,11 @@ export function preprocessRantMarkdown(text?: string | null): string { /* ── 项目面板工具 ────────────────────────────────────────── */ -/** auto_evolve 徽标集合:tasks.yml 中 config.project 与项目名相同(含 evolution 类型) */ +/** auto_evolve 徽标集合:tasks.yml 中 config.agent 与项目名相同(含 evolution 类型) */ export function buildEvolveProjects(tasks: TaskRec[] | null | undefined): Set { const set = new Set(); for (const t of tasks || []) { - const proj = t?.config?.project; + const proj = t?.config?.agent; if (proj) set.add(proj); } return set; @@ -202,7 +202,7 @@ export function taskStatusBadge(t: TaskRec, tFn?: TFunc, now: number = Date.now( export function taskHint(t: TaskRec, tFn?: TFunc): string { const tr = tFn || ((k: string) => k); const parts: string[] = []; - if (t.config?.project) parts.push(t.config.project); + if (t.config?.agent) parts.push(t.config.agent); if (t.interval != null) parts.push(tr("app.taskInterval", { n: t.interval })); if (t.enabled === false) parts.push(tr("app.taskDisabled")); return parts.join(" · "); diff --git a/emrg/server/__main__.py b/emrg/server/__main__.py index 58c05567b..db764fc7d 100644 --- a/emrg/server/__main__.py +++ b/emrg/server/__main__.py @@ -9,6 +9,7 @@ from emrg.logfiles import migrate_legacy_logs from emrg.server.daemon import DaemonExit, _non_regular_kind, run_server from emrg.server.logcontext import session_label +from emrg.server.scheduler import migrate_task_config_keys from emrg.tool_path import ensure_tool_dirs @@ -202,6 +203,13 @@ def main() -> None: # fails without touching the host's register at all, and it sits in no path # derivation — asking where the register lives must never move it. migrate_legacy_registry() + # The task table's agent key moved from `project` to `agent` (issue #2531), + # and it moves here for the same reason: this is a process that owns the + # file, it runs before the scheduler is constructed below, and no test path + # reaches it — so a suite run cannot rewrite a host's live table under a + # daemon still reading the old key. Asking a record for its agent never + # writes; only this call does. + migrate_task_config_keys() result = asyncio.run(run_server(config.llm)) except KeyboardInterrupt: # SIGINT delivered at the event-loop poll point escapes the main diff --git a/emrg/server/competition_prompt.md b/emrg/server/competition_prompt.md index cba45679b..e88f652a5 100644 --- a/emrg/server/competition_prompt.md +++ b/emrg/server/competition_prompt.md @@ -17,7 +17,7 @@ You are EMRG's competition participation module. **Every cycle you MUST fully ex 5. **Think before acting.** Before an irreversible or quota-consuming action — a submission, a registration, starting a long run — state in the reply **what will be done, on what evidence, the expected result, and the cost of failure**. If the evidence is not there yet, go and get it rather than doing it to find out. ### Current State -- Task project: **{{ task.project }}** (from tasks.yml) +- Task agent: **{{ task.agent }}** (from tasks.yml) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the time anchor for judging deadlines @@ -58,7 +58,7 @@ Cross-round continuity comes from what the daemon already gives you: it loads th Rants are how the host sends instructions directly to a task. -- A rant matches **only if its `project` field is exactly `{{ task.project }}`**. Rants that do not match are ignored completely. +- A rant matches **only if its `project` field is exactly `{{ task.agent }}`**. Rants that do not match are ignored completely. - Unmatched but seemingly relevant rants must be **counted and reported to the host** — never silently dropped. - A matching rant is an instruction for this round: it takes priority over the round's planned work. diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 84c1ae449..64cc813f3 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2937,7 +2937,7 @@ async def _process_message( if self._scheduler: ok, res = self._scheduler.task_create( name=name, task_type="evolution", - project=name, interval=600, + agent=name, interval=600, ) if ok: self._scheduler.reconcile() @@ -3004,7 +3004,7 @@ async def _process_message( ok, res = self._scheduler.task_create( name=msg.get("name", "").strip(), task_type=msg.get("task_type", "").strip(), - project=msg.get("project", "").strip(), + agent=msg.get("agent", "").strip(), interval=msg.get("interval"), enabled=msg.get("enabled", True), repo=msg.get("repo"), @@ -3026,7 +3026,7 @@ async def _process_message( if not self._scheduler: await self._send(ws, {"type": "task_result", "error": "scheduler not running"}) return - fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox", "schedule") if k in msg} + fields = {k: msg[k] for k in ("task_type", "agent", "interval", "enabled", "repo", "description", "sandbox", "schedule") if k in msg} if "task_type" in fields: fields["type"] = fields.pop("task_type") ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields) diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index d4f666d05..3fd3e34b2 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -221,7 +221,7 @@ Two ways this source goes quiet, and both are legitimate: an installation whose - **Issue**: something needing a reply or triage, or a resolved one to close — the close itself is §3.1. What finishes an issue, and how the link reading is read, is R5. - **Rant**: take each one through three questions. - **Has it been handled?** `git log --oneline -20` for a commit naming its timestamp or keywords. Naming proves only that it was **touched**, never that it is finished — check for unmet acceptance items, unmerged branches, and a stage that landed without finishing the work. - - **Does it match this task?** A rant's `project` matches if it is `{{ task.project }}` or `{{ owner }}/{{ repo }}`; a rant that names neither is not this instance's to act on — **ignore rants without a `project` field entirely**. + - **Does it match this task?** A rant's `project` matches if it is `{{ task.agent }}` or `{{ owner }}/{{ repo }}`; a rant that names neither is not this instance's to act on — **ignore rants without a `project` field entirely**. - **Is it work?** A rant this cycle decides against is closed with the reason in its progress; a rant taken on becomes an issue before any code is written (R5). The lifecycle is R6. **Before any reason is written down as the host's ruling, R7.** #### 2.3 Decide this cycle's work diff --git a/emrg/server/feishu_prompt.md b/emrg/server/feishu_prompt.md index 38346511f..0ff43a841 100644 --- a/emrg/server/feishu_prompt.md +++ b/emrg/server/feishu_prompt.md @@ -4,7 +4,7 @@ You are EMRG handling the host's Feishu messages: read what arrived, judge each what needs no permission, ask where it does. ### Current State -- Task project: **{{ task.project }}** (from tasks.yml) +- Task agent: **{{ task.agent }}** (from tasks.yml) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the anchor for "since the last round" and for every dated record @@ -47,7 +47,7 @@ in the usual install locations before calling it blocked. task instructions. - Take the messages that arrived after the last one you handled. Remember that boundary yourself; there is no state file to keep and none to trust. -- Rants whose `project` is `{{ task.project }}` are the host's instructions for this round +- Rants whose `project` is `{{ task.agent }}` are the host's instructions for this round and outrank the plan; those that do not match are counted, not acted on. - A CLI that cannot run is a `blocked` item reported to the host, and so is an account that really cannot authenticate — but `missing identity` is a symptom, not a cause. The CLI diff --git a/emrg/server/ios_app_prompt.md b/emrg/server/ios_app_prompt.md index cbab252fa..5d3c1077e 100644 --- a/emrg/server/ios_app_prompt.md +++ b/emrg/server/ios_app_prompt.md @@ -12,7 +12,7 @@ You are EMRG as the **product owner** of iOS applications: you choose what to bu **Every round runs §0 Preparation first (§0.1–§0.7), whichever phase it then enters.** ### Current State -- Task project: **{{ task.project }}** (from tasks.yml) +- Task agent: **{{ task.agent }}** (from tasks.yml) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the time anchor for judging review turnaround and every dated record @@ -60,7 +60,7 @@ Everything needed to build, sign and upload an iOS app is installed. **Adding a Rants are how the host sends instructions directly to a task. -- A rant matches **only if its `project` field is exactly `{{ task.project }}`**. Rants that do not match are ignored completely. +- A rant matches **only if its `project` field is exactly `{{ task.agent }}`**. Rants that do not match are ignored completely. - Unmatched but seemingly relevant rants must be **counted and reported to the host**, never silently dropped. - A matching rant is an instruction for this round and takes priority over the round's planned work. diff --git a/emrg/server/journal_prompt.md b/emrg/server/journal_prompt.md index db3af3c07..7129b9a07 100644 --- a/emrg/server/journal_prompt.md +++ b/emrg/server/journal_prompt.md @@ -127,9 +127,9 @@ cat ~/.emrg/rants.jsonl 2>/dev/null || echo "[no rants.jsonl — skip rant scan] ``` Filter rules (same as open-source tasks): -- Match rant's `project` against exactly `{{ task.project }}` — equal counts, anything else does not +- Match rant's `project` against exactly `{{ task.agent }}` — equal counts, anything else does not - Ignore rants without a `project` field; only `pending` / `in_progress` count -- **Irrelevant-rant exclusion (HARD)**: any rant that does NOT match `project` exactly, or whose content is not about THIS journal, must be **ignored entirely** — do NOT read it into your thinking, do NOT adopt it as a research direction, do NOT cite it. **An irrelevant rant must never become a topic anchor.** Only a rant with `project` = `{{ task.project }}` AND content actually directed at this journal enters the candidate pool / Phase Ops. +- **Irrelevant-rant exclusion (HARD)**: any rant that does NOT match `project` exactly, or whose content is not about THIS journal, must be **ignored entirely** — do NOT read it into your thinking, do NOT adopt it as a research direction, do NOT cite it. **An irrelevant rant must never become a topic anchor.** Only a rant with `project` = `{{ task.agent }}` AND content actually directed at this journal enters the candidate pool / Phase Ops. - **Dedup check** before treating any rant as actionable: search the journal repo commit log for the rant's timestamp/keywords — a commit referencing the rant only counts as handled when ALL acceptance items are satisfied AND related PRs merged - **Rant status machine**: `pending → in_progress → completed` (never jump pending → completed). When starting work on a rant: set `in_progress` + progress note. When all its PRs merged + self-verification passes: set `completed` + ISO timestamp. Host feedback that a fix is insufficient → revert to `in_progress` with reason. - Rant-driven journal ops (e.g. "adjust CfP", "revise review policy") are processed in Phase Ops with highest priority. @@ -370,7 +370,7 @@ Nothing pending? → Phase Research (new di - Read the 2–3 most relevant recent abstracts (last 6 months), record their limitations → this forms your gap. If browser-harness is available, prefer it for reading full texts. - **The editor's CfP / journal themes**: README, CfP issue, editorial comments — topics the journal explicitly wants. - **Your own papers' future work**: Discussion / Future-Work paragraphs of your published or in-review manuscripts. **Own-work relevance is NOT by itself a topic reason** — it is only one candidate among many. - - **Host rants with `project` = {{ task.project }}**: directional feedback, not just bug reports. (Per §0.5, irrelevant rants are excluded entirely.) + - **Host rants with `project` = {{ task.agent }}**: directional feedback, not just bug reports. (Per §0.5, irrelevant rants are excluded entirely.) - **Uncovered gaps in the journal**: open issues / published papers that raise questions nobody has answered yet. - **Data-source debias**: having data for a particular project on hand is NOT a topic reason. Prefer directions backed by external data sources (public datasets, open-source repositories, simulations) or where data can be obtained/created during the research. 2. **Research-hotspot identification** (using the current-time anchor; run in parallel with the candidate pool): @@ -544,7 +544,7 @@ cd {{ source_dir }} && gh issue list -R {{ owner }}/{{ repo }} --label in-review 1. **Never touch host dirt**: never stash/reset/clean host work — the daemon converges the tree itself before the prompt, so a tree still dirty here is a convergence that failed (and the round is then read-only); `papers/*/research/` being present is normal 2. **git add exact path**: submission/revision commits use `git add papers/issue-/` — never `-A` / `.` -3. **Rant handling**: follow 0.5 — project must equal `{{ task.project }}`; pending → in_progress → completed +3. **Rant handling**: follow 0.5 — project must equal `{{ task.agent }}`; pending → in_progress → completed 4. **Language policy**: external journal-facing text (issues/PRs/reviews/decisions) in English; internal records (session history, memory entries, the closing summary) in the author's language 5. **One thing at a time**: advance exactly one phase per cycle; don't aim for completeness, just for progress 6. **Never claim/review your own submission** (author) diff --git a/emrg/server/open_source_prompt.md b/emrg/server/open_source_prompt.md index 5594697ff..405d106d6 100644 --- a/emrg/server/open_source_prompt.md +++ b/emrg/server/open_source_prompt.md @@ -111,9 +111,9 @@ For each one: **merged** → read what landed and whether anything follows from The queue is read **through the tool** — `submit_rant(action="list")` — never by opening the file by hand; the file is the store, the tool is the reader. -A rant is a **work order for THIS repository** when its `project` field equals this task's `config.project` — **`{{ task.project }}`** — and nothing else counts: a rant filed as `{{ owner }}/{{ repo }}` does **NOT** match, and a rant with no `project` field at all is ignored entirely. Only `pending` and `in_progress` rants are candidates. +A rant is a **work order for THIS repository** when its `project` field equals this task's `config.project` — **`{{ task.agent }}`** — and nothing else counts: a rant filed as `{{ owner }}/{{ repo }}` does **NOT** match, and a rant with no `project` field at all is ignored entirely. Only `pending` and `in_progress` rants are candidates. -**Unmatched-rant hint**: if rants are still pending whose `project` merely *starts with* `{{ task.project }}` or `{{ owner }}/{{ repo }}`, record the count in the closing summary so the host can correct the field — never pass over them in silence. +**Unmatched-rant hint**: if rants are still pending whose `project` merely *starts with* `{{ task.agent }}` or `{{ owner }}/{{ repo }}`, record the count in the closing summary so the host can correct the field — never pass over them in silence. **Dedup check — before treating any candidate as actionable:** diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 045902476..b6a2eaa98 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -15,7 +15,7 @@ clock and whatever its zone does with DST. Weekdays only: no holidays, no make-up workdays. config — type-specific config, and the mapping the templates see as ``task``. For - evolution: config.project links to agents.yml name. + evolution: config.agent links to agents.yml name. sandbox — optional; also accepted under config, where the record-level value wins. extra_prompt — optional; also accepted at the record's top level, where config wins (the templates read it as ``task.extra_prompt``; see ``_template_task_context``). @@ -543,7 +543,7 @@ def _silence_deadline(now: float, last_frame_at: float, tool_deadline: float | N DEFAULT_TASK_RECORD: dict = { "name": "emrg-task", "type": "evolution", - "config": {"project": "emrg"}, + "config": {"agent": "emrg"}, "interval": 600, "enabled": True, } @@ -769,11 +769,70 @@ def write_table(records: list[dict], path: Path | None = None) -> None: atomic_write_yaml(records, path, prefix=".tasks_", dumper=_TaskTableDumper) +def migrate_task_config_keys(path: Path | None = None) -> int: + """Move a task record's agent key from ``project`` to ``agent``, in place. + + Returns how many records this call rewrote, so a caller can say whether it did + anything rather than guess. + + **One spelling, no compat read** (issue #2531). The resolution reads + ``config.agent`` and only ``config.agent``: a tree that answered both spellings + would keep alive exactly the ambiguity the rename exists to remove — every + reader would again have to decide which of the two a given record meant. So the + old key is not a fallback; it is moved, once, and then it is gone. + + **Called from the daemon's process entry and nowhere else.** The same separation + `emrg.config.migrate_legacy_registry` uses, for the same reason: the test suite + constructs schedulers and drives `load_and_start` against tables of its own, and + a migration reachable from those paths would rewrite a host's live ``tasks.yml`` + under a daemon that is still reading the old key. The reader never writes — + :func:`read_table` returns a record exactly as written, old key and all — which is + the property ``test_scheduler.py`` pins. + + A record is rewritten at the key's own position, so every other key, its order and + its value travel across unchanged. A record already carrying ``agent`` keeps it + and loses the stale ``project`` rather than gaining a second value. A record with + neither is untouched, a table that cannot be read is left exactly where it lies + (the caller is starting up, and refusing to start over a table it could not parse + would be a worse failure than the one it reports), and a file that does not exist + is not created — this moves an existing table, it never seeds one. + """ + path = path or _tasks_yml_path() + if not path.exists(): + return 0 + try: + records = read_table(path) + except TableUnreadable as exc: + logger.warning("TaskScheduler: %s not migrated — %s", path, exc) + return 0 + changed = 0 + for record in records: + cfg = record.get("config") + if not isinstance(cfg, dict) or "project" not in cfg: + continue + moved: dict = {} + for key, value in cfg.items(): + if key == "project": + if "agent" not in cfg: + moved["agent"] = value + continue + moved[key] = value + record["config"] = moved + changed += 1 + if changed: + write_table(records, path) + logger.info( + "TaskScheduler: %s migrated — %d task record(s) moved to config.agent", + path, changed, + ) + return changed + + def _template_task_context(config: dict, record: dict) -> dict: """The ``task`` mapping the prompt templates render against. ``task`` is the record's ``config``: every name a template reads there — - ``extra_prompt``, ``project``, ``role``, ``author_id``, ``keywords``, ``platform`` — + ``extra_prompt``, ``agent``, ``role``, ``author_id``, ``keywords``, ``platform`` — lives under ``config:`` in tasks.yml. ``extra_prompt`` is **also honoured at the record's top level**, which is the position @@ -1133,7 +1192,7 @@ def _apply_record(self, record: dict) -> None: cfg = record.get("config") if isinstance(record.get("config"), dict) else {} # Resolve project path from config (new schema) or fall back to # config.path for backward-compat with old tasks.yml entries. - project_name = cfg.get("project", "") + project_name = cfg.get("agent", "") path = _resolve_project_path(project_name) if project_name else cfg.get("path", "") # Derive owner/repo/git from config override → path git remote → defaults. # (rant 2026-08-19T14:20:52: workspace self-heal deleted — these fields @@ -3583,7 +3642,7 @@ def _idle_status(self, record: dict) -> dict: started before it can render the row. """ cfg = record.get("config") if isinstance(record.get("config"), dict) else {} - project = cfg.get("project", "") + project = cfg.get("agent", "") # Issue #2486 ⑦: the same three cadence keys the live handler reports — a # client must not have to know whether a task has been started before it can # render the frequency that is in force, or when it next changes. @@ -3678,7 +3737,7 @@ def _ensure_emrg_project_entry(self) -> None: @staticmethod def _validate_task_fields( - name: str, task_type: str, project: str, interval: int, + name: str, task_type: str, agent: str, interval: int, schedule: list | None = None, ) -> str | None: """Return an error string, or None when the fields are valid.""" @@ -3686,22 +3745,22 @@ def _validate_task_fields( return f"invalid task name {name!r} ({TASK_NAME_PATTERN}, <= {TASK_NAME_MAX} chars)" if task_type not in TASK_TEMPLATES and task_type not in _custom_templates(): return f"unknown task type {task_type!r} (not builtin, no custom template)" - if not project or _resolve_project_path(project) is None: - return f"project {project!r} is not registered in agents.yml" + if not agent or _resolve_project_path(agent) is None: + return f"agent {agent!r} is not registered in agents.yml" if not isinstance(interval, int) or isinstance(interval, bool) or interval < MIN_INTERVAL: return f"interval must be an integer >= {MIN_INTERVAL}" # The same validator the loader runs: one shape, one set of messages. return validate_schedule(schedule) def task_create( - self, name: str, task_type: str, project: str, + self, name: str, task_type: str, agent: str, interval: int | None = None, enabled: bool = True, repo: str | None = None, description: str | None = None, sandbox: str | None = None, schedule: list | None = None, ) -> tuple[bool, str | dict]: """Create a task. Returns (ok, error) or (ok, task-dict).""" interval = DEFAULT_INTERVAL if interval is None else interval - err = self._validate_task_fields(name, task_type, project, interval, schedule) + err = self._validate_task_fields(name, task_type, agent, interval, schedule) if err: return False, err if sandbox is not None and sandbox not in SANDBOX_MODES: @@ -3716,7 +3775,7 @@ def task_create( "the name is a file name and a session id, and those are not case-sensitive " "on every platform)" ) - cfg: dict = {"project": project} + cfg: dict = {"agent": agent} if repo: cfg["repo"] = repo task: dict = { @@ -3744,23 +3803,23 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]: if task is None: return False, f"task {name!r} not found" new_type = fields.get("type", task.get("type", "evolution")) - new_project = fields.get("project") - if new_project is None: + new_agent = fields.get("agent") + if new_agent is None: cfg = task.get("config") if isinstance(task.get("config"), dict) else {} - new_project = cfg.get("project", "") + new_agent = cfg.get("agent", "") new_interval = fields.get("interval", task.get("interval", DEFAULT_INTERVAL)) new_schedule = fields.get("schedule", task.get("schedule")) err = self._validate_task_fields( - name, new_type, new_project, new_interval, new_schedule, + name, new_type, new_agent, new_interval, new_schedule, ) if err: return False, err if "type" in fields: task["type"] = fields["type"] - if "project" in fields or "repo" in fields: + if "agent" in fields or "repo" in fields: cfg = task.get("config") if isinstance(task.get("config"), dict) else {} - if "project" in fields: - cfg["project"] = fields["project"] + if "agent" in fields: + cfg["agent"] = fields["agent"] if "repo" in fields: if fields["repo"]: cfg["repo"] = fields["repo"] diff --git a/tests/test_agents_registry_migration.py b/tests/test_agents_registry_migration.py index 344ec248d..8954d30d9 100644 --- a/tests/test_agents_registry_migration.py +++ b/tests/test_agents_registry_migration.py @@ -126,6 +126,11 @@ def test_the_daemon_entrypoint_migrates_before_it_serves(monkeypatch): "migrate_legacy_registry", lambda *a, **k: calls.append("migrate") or True, ) + monkeypatch.setattr( + server_main, + "migrate_task_config_keys", + lambda *a, **k: calls.append("migrate-tasks") or 0, + ) monkeypatch.setattr( "emrg.config.load_config", lambda: types.SimpleNamespace(llm=None), @@ -146,7 +151,7 @@ async def _run_server(llm): with pytest.raises(SystemExit): server_main.main() - assert calls == ["migrate", "serve"], calls + assert calls == ["migrate", "migrate-tasks", "serve"], calls def test_the_old_name_is_spelled_only_where_the_move_is(): diff --git a/tests/test_competition_prompt.py b/tests/test_competition_prompt.py index b42fa20be..63b6813cc 100644 --- a/tests/test_competition_prompt.py +++ b/tests/test_competition_prompt.py @@ -75,7 +75,7 @@ def rendered(tmp_path_factory) -> str: handler = make_handler( name="competition-task", type="competition", - config={"project": PROMPT_PROJECT}, + config={"agent": PROMPT_PROJECT}, identity=InstanceIdentity(), template_path=PROMPT, ) @@ -353,7 +353,7 @@ def test_preparation_and_closing_summary_are_mandatory(): assert "a round that ends without a summary strands the next one" in text assert "Seven questions the closing summary must answer" in text # Rant scan must match the project field exactly, like the other templates. - assert "exactly `{{ task.project }}`" in text + assert "exactly `{{ task.agent }}`" in text def test_error_handling_and_forbidden_sections_exist(): @@ -998,7 +998,7 @@ def test_the_render_substitutes_values_and_carries_no_template_syntax(rendered): Two ways a render assertion goes vacuous, both closed here. If the block were read with its syntax intact — a `{% raw %}` wrapper, or the text copied out of a literal — - the round would be sent `{{ task.project }}` and would be told nothing; and if what + the round would be sent `{{ task.agent }}` and would be told nothing; and if what this test examined were the file, the same assertion catches it. Then the substituted value has to arrive, so "it rendered" is measured by what the round can read rather than by the absence of braces alone. @@ -1010,7 +1010,7 @@ def test_the_render_substitutes_values_and_carries_no_template_syntax(rendered): assert PROMPT_PROJECT in rendered, ( "the render does not carry the project name " f"{PROMPT_PROJECT!r}: " - "`{{ task.project }}` resolved to nothing (`jinja2.Undefined` renders empty), so " + "`{{ task.agent }}` resolved to nothing (`jinja2.Undefined` renders empty), so " "the round is told its project is blank" ) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index a0d445db3..1500d3ee4 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -5784,9 +5784,9 @@ def test_the_tasks_list_frame_carries_the_minimum_interval(tmp_path, monkeypatch sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "windowed", "type": "evolution", "config": {"project": "emrg"}, + {"name": "windowed", "type": "evolution", "config": {"agent": "emrg"}, "interval": 10800, "enabled": True, "schedule": windows}, - {"name": "plain", "type": "evolution", "config": {"project": "emrg"}, + {"name": "plain", "type": "evolution", "config": {"agent": "emrg"}, "interval": 1800, "enabled": True}, ])) diff --git a/tests/test_daemon_tool_path.py b/tests/test_daemon_tool_path.py index 84e97d6cf..305ae3780 100644 --- a/tests/test_daemon_tool_path.py +++ b/tests/test_daemon_tool_path.py @@ -344,6 +344,9 @@ def test_the_launcher_entry_normalizes_the_path(monkeypatch): # Stubbed so the test opens no host file: these are the real entry's side effects. monkeypatch.setattr(server_main, "_configure_logging", lambda: None) monkeypatch.setattr(server_main, "_redirect_std_streams", lambda: None) + # Stubbed for the same reason as the logging hooks above: the real call moves a + # key in the host's tasks.yml, and a test opens no host file. + monkeypatch.setattr(server_main, "migrate_task_config_keys", lambda *a, **k: 0) monkeypatch.setattr("emrg.config.load_config", lambda: types.SimpleNamespace(llm=object())) class _Exit: diff --git a/tests/test_evolution_prompt_index_rule.py b/tests/test_evolution_prompt_index_rule.py index ce4d2e63a..417ed7338 100644 --- a/tests/test_evolution_prompt_index_rule.py +++ b/tests/test_evolution_prompt_index_rule.py @@ -116,7 +116,7 @@ def rendered(tmp_path_factory) -> str: try: handler = make_handler( name="demo-task", - config={"project": "demoproj"}, + config={"agent": "demoproj"}, interval=300, identity=InstanceIdentity(), template_path=PROMPTS_DIR / "evolution_prompt.md", diff --git a/tests/test_evolution_prompt_merge_condition.py b/tests/test_evolution_prompt_merge_condition.py index 992e96777..bcc6b4e44 100644 --- a/tests/test_evolution_prompt_merge_condition.py +++ b/tests/test_evolution_prompt_merge_condition.py @@ -119,7 +119,7 @@ def rendered(tmp_path_factory) -> str: try: handler = make_handler( name="demo-task", - config={"project": "demoproj"}, + config={"agent": "demoproj"}, interval=300, identity=InstanceIdentity(), template_path=PROMPTS_DIR / "evolution_prompt.md", diff --git a/tests/test_evolution_prompt_vote_body.py b/tests/test_evolution_prompt_vote_body.py index cd57e2257..c7f6d9b39 100644 --- a/tests/test_evolution_prompt_vote_body.py +++ b/tests/test_evolution_prompt_vote_body.py @@ -93,7 +93,7 @@ def rendered(tmp_path, monkeypatch) -> str: ) handler = make_handler( name="demo-task", - config={"project": "demoproj"}, + config={"agent": "demoproj"}, interval=300, identity=InstanceIdentity(), template_path=PROMPTS_DIR / "evolution_prompt.md", diff --git a/tests/test_prompt_templates.py b/tests/test_prompt_templates.py index c8fb10814..6bb29ff24 100644 --- a/tests/test_prompt_templates.py +++ b/tests/test_prompt_templates.py @@ -62,7 +62,7 @@ # Optional task/project fields the prompts guard on. Populated only by the strict # render, so that a "missing" name there is a typo rather than a design choice. FULL_TASK_CONFIG: dict = { - "project": "demoproj", + "agent": "demoproj", "role": "committer", "author_id": "argszero", "keywords": "demo, wiring", @@ -95,7 +95,7 @@ def _make_handler( ) -> TaskHandler: """A real handler, from a hermetic config dir, pointed at a real template.""" monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) - name = config.get("project", "demoproj") + name = config.get("agent", "demoproj") _write_projects_yml(tmp_path, name, project_entry or {"name": name}) return make_handler( name="demo-task", @@ -112,7 +112,7 @@ def _builtin_templates() -> list[tuple[str, str]]: def test_every_prompt_template_renders_through_the_real_builder(tmp_path, monkeypatch) -> None: """Each built-in task type renders, with the optional sections unset.""" - minimal = {"project": "demoproj"} + minimal = {"agent": "demoproj"} for task_type, filename in _builtin_templates(): handler = _make_handler(tmp_path, monkeypatch, filename, minimal) rendered = handler._build_evolution_prompt() diff --git a/tests/test_schedule_window.py b/tests/test_schedule_window.py index 8852db6f9..dc7e8f3df 100644 --- a/tests/test_schedule_window.py +++ b/tests/test_schedule_window.py @@ -49,7 +49,7 @@ def _record(**overrides) -> dict: record = { "name": "windowed", "type": "evolution", - "config": {"project": "emrg"}, + "config": {"agent": "emrg"}, "interval": 10800, "enabled": True, "schedule": [ @@ -271,7 +271,7 @@ def _row(tasks_yml, name: str) -> dict: def test_create_and_update_carry_the_windows_through_the_file(sched): ok, created = sched.task_create( - name="windowed", task_type="evolution", project="emrg", + name="windowed", task_type="evolution", agent="emrg", interval=10800, schedule=WINDOW, ) assert ok, created @@ -292,12 +292,12 @@ def test_create_and_update_carry_the_windows_through_the_file(sched): def test_a_bad_window_is_refused_on_both_write_paths(sched): bad = [{"days": "mon", "from": "18:00", "to": "09:00", "interval": 900}] ok, error = sched.task_create( - name="bad", task_type="evolution", project="emrg", interval=10800, schedule=bad, + name="bad", task_type="evolution", agent="emrg", interval=10800, schedule=bad, ) assert not ok and "does not move forward" in error, error assert sched.task_create( - name="good", task_type="evolution", project="emrg", interval=10800, + name="good", task_type="evolution", agent="emrg", interval=10800, )[0] ok, error = sched.task_update("good", schedule=bad) assert not ok and "does not move forward" in error, error @@ -310,7 +310,7 @@ def test_list_tasks_reports_the_effective_interval_and_the_next_edge(sched): """A record with no handler reports the same cadence keys a live one does.""" all_day = [{"days": None, "from": "00:00", "to": "24:00", "interval": 900}] sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "always", "type": "evolution", "config": {"project": "emrg"}, + {"name": "always", "type": "evolution", "config": {"agent": "emrg"}, "interval": 10800, "enabled": True, "schedule": all_day}, ])) row = sched.list_tasks()[0] diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index e33bf5683..df0d0f748 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -37,7 +37,7 @@ def test_task_handler_logger_adapter_carries_task_column(tmp_path): try: handler = make_handler( name="emrg-task", - config={"project": "emrg"}, + config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) @@ -124,7 +124,7 @@ def test_write_table_replaces_atomically(tmp_path): tasks_yml.write_text(yaml.safe_dump([{"name": "old"}])) write_table([ - {"name": "test1", "type": "evolution", "config": {"project": "emrg"}}, + {"name": "test1", "type": "evolution", "config": {"agent": "emrg"}}, ], tasks_yml) data = yaml.safe_load(tasks_yml.read_text()) @@ -224,7 +224,7 @@ def _task_create_fixture(tmp_path, monkeypatch, sched): ) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "emrg-task", "type": "evolution", "config": {"project": "emrg"}}, + {"name": "emrg-task", "type": "evolution", "config": {"agent": "emrg"}}, ])) @@ -260,9 +260,9 @@ def test_task_delete_and_update_do_not_touch_other_records(tmp_path, monkeypatch sched = TaskScheduler(InstanceIdentity()) _task_create_fixture(tmp_path, monkeypatch, sched) sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "emrg-task", "type": "evolution", "config": {"project": "emrg"}}, - {"name": "keep", "type": "evolution", "config": {"project": "proj"}, "interval": 300}, - {"name": "drop", "type": "evolution", "config": {"project": "proj"}, "interval": 400}, + {"name": "emrg-task", "type": "evolution", "config": {"agent": "emrg"}}, + {"name": "keep", "type": "evolution", "config": {"agent": "proj"}, "interval": 300}, + {"name": "drop", "type": "evolution", "config": {"agent": "proj"}, "interval": 400}, ])) before = yaml.safe_load(sched._tasks_file.read_text()) @@ -336,7 +336,7 @@ def test_load_and_start_no_file_seeds_the_default_table(tmp_path): def test_load_and_start_enabled_task(tmp_path): """Starts a coroutine for each enabled task, and the interval is the record's.""" snapshot = asyncio.run(_boot(tmp_path, [ - {"name": "emrg", "type": "evolution", "config": {"project": "emrg"}, + {"name": "emrg", "type": "evolution", "config": {"agent": "emrg"}, "interval": 99, "enabled": True}, ])) @@ -350,7 +350,7 @@ def test_load_and_start_enabled_task(tmp_path): def test_load_and_start_skips_disabled(tmp_path): """Disabled tasks are not started.""" snapshot = asyncio.run(_boot(tmp_path, [ - {"name": "enabled", "type": "evolution", "config": {"project": "emrg"}, "enabled": True}, + {"name": "enabled", "type": "evolution", "config": {"agent": "emrg"}, "enabled": True}, {"name": "disabled", "type": "evolution", "config": {"path": "/tmp"}, "enabled": False}, ])) @@ -405,7 +405,7 @@ def test_load_and_start_competition_task(tmp_path): """ snapshot = asyncio.run(_boot(tmp_path, [ {"name": "competition-task", "type": "competition", - "config": {"project": "competitions"}, + "config": {"agent": "competitions"}, "interval": 3600, "enabled": True}, ])) @@ -428,7 +428,7 @@ def test_load_and_start_ios_app_task(tmp_path): """ snapshot = asyncio.run(_boot(tmp_path, [ {"name": "ios-app-task", "type": "ios-app", - "config": {"project": "iosapps"}, + "config": {"agent": "iosapps"}, "interval": 3600, "enabled": True}, ])) @@ -504,7 +504,7 @@ def test_load_and_start_feishu_task(tmp_path): """ snapshot = asyncio.run(_boot(tmp_path, [ {"name": "feishu-task", "type": "feishu", - "config": {"project": "emrg"}, + "config": {"agent": "emrg"}, "interval": 900, "enabled": True}, ])) @@ -595,7 +595,7 @@ def test_load_and_start_promote_task(tmp_path): """Promote tasks start a TaskHandler with the promote template.""" snapshot = asyncio.run(_boot(tmp_path, [ {"name": "olr-promote", "type": "promote", - "config": {"project": "openlocalrouter"}, + "config": {"agent": "openlocalrouter"}, "interval": 3600, "enabled": True}, ])) @@ -672,7 +672,7 @@ def test_load_and_start_journal_task(tmp_path): """Journal tasks start a TaskHandler with the journal template.""" snapshot = asyncio.run(_boot(tmp_path, [ {"name": "journal-task", "type": "journal", - "config": {"project": "mem", "role": "author", "author_id": "author-a"}, + "config": {"agent": "mem", "role": "author", "author_id": "author-a"}, "interval": 3600, "enabled": True}, ])) @@ -720,7 +720,7 @@ def test_journal_template_renders_with_context(): ctx = dict( instance_id="test", host_name="host", owner="x", repo="y", source_dir="/tmp/j", timestamp="20260824-182903", current_time_human="2026-08-24 18:29", - task={"role": "author", "project": "j", "author_id": "author-a"}, + task={"role": "author", "agent": "j", "author_id": "author-a"}, ) out = template.render(**ctx) # Current-time injection (rant 18:19:08) @@ -1037,7 +1037,7 @@ def test_task_handler_emrg_configured_by_default(): """The emrg evolution task is always repo-configured (defaults to argszero/emrg).""" handler = make_handler( name="emrg-task", - config={"project": "emrg"}, + config={"agent": "emrg"}, interval=1800, identity=InstanceIdentity(), ) @@ -1049,7 +1049,7 @@ def test_task_handler_repo_configured_from_config(): """config owner/repo overrides the default and enables self-heal (rant 18:14:46 P1).""" handler = make_handler( name="paper-x", - config={"project": "some-proj", "owner": "acme", "repo": "paper-x"}, + config={"agent": "some-proj", "owner": "acme", "repo": "paper-x"}, interval=1800, identity=InstanceIdentity(), ) @@ -1123,7 +1123,7 @@ def test_journal_template_renders_the_continuity_contract(): template = env.from_string(template_path.read_text(encoding="utf-8")) out = template.render( instance_id="test", host_name="host", source_dir="/tmp/journal", session_id="s1", timestamp="20260919-100129", - current_time_human="2026-09-19 10:01", task={"role": "editor", "project": "silicon-science-cs"}, + current_time_human="2026-09-19 10:01", task={"role": "editor", "agent": "silicon-science-cs"}, project={}, owner="argszero", repo="silicon-science-cs", ) # The state and reflection files were retired (rant 2026-09-14T14:35:47): the @@ -1152,7 +1152,7 @@ def test_open_source_template_renders_with_context(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260813", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) assert "The rant queue" in out, "rant-scan 0.5 节应渲染" @@ -1160,7 +1160,7 @@ def test_open_source_template_renders_with_context(): assert "config.project" in out, "project 匹配过滤应渲染" # Rant 2026-08-17T14:17:03: rant project matching is the SINGLE value # config.project — owner/repo dual-compat removed (host decision). - assert "aitokenpool" in out, "task.project 值应渲染进 0.5 节" + assert "aitokenpool" in out, "task.agent 值应渲染进 0.5 节" assert "does **NOT** match" in out, "owner/repo 形式明确不匹配" # the unmatched-rant hint may still mention the owner/repo form for # detecting rants the host should fix — that is a hint, not a match rule @@ -1200,13 +1200,13 @@ def test_open_source_template_allow_self_merge_conditional(): ) # default (allow_self_merge absent) → rule stands, no override - out_default = template.render(task={"role": "committer", "project": "aitokenpool"}, **base) + out_default = template.render(task={"role": "committer", "agent": "aitokenpool"}, **base) assert "Do not merge your own PRs (wait for other Committers to review)" in out_default assert "allow_self_merge: true" not in out_default # explicit true → override text appears + opt-in note in role section out_true = template.render( - task={"role": "committer", "project": "aitokenpool", "allow_self_merge": True}, **base + task={"role": "committer", "agent": "aitokenpool", "allow_self_merge": True}, **base ) assert "allow_self_merge" in out_true, "allow_self_merge 说明应渲染" assert "may review and merge their own PRs" in out_true, "self-merge 允许说明应渲染" @@ -1214,7 +1214,7 @@ def test_open_source_template_allow_self_merge_conditional(): # explicit false → same as default out_false = template.render( - task={"role": "committer", "project": "aitokenpool", "allow_self_merge": False}, **base + task={"role": "committer", "agent": "aitokenpool", "allow_self_merge": False}, **base ) assert "Do not merge your own PRs (wait for other Committers to review)" in out_false assert "**overridden**" not in out_false @@ -1269,7 +1269,7 @@ def test_open_source_template_never_stashes_host_work(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260820", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # the old data-losing instruction is gone @@ -1301,7 +1301,7 @@ def test_open_source_template_full_code_study_b2b(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260814", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the new section exists (positive discrimination: absent section → red) @@ -1335,7 +1335,7 @@ def test_open_source_template_measures_the_shipped_artifact(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260814", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) a defect is measured on the shipped artifact, not only on the checkout @@ -1379,7 +1379,7 @@ def test_open_source_template_distinguishes_an_unrunnable_toolchain(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260814", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the resolver runs before the suite and says what an absent tool is not @@ -1418,7 +1418,7 @@ def test_open_source_template_parallel_recon_c15(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the new section exists @@ -1472,7 +1472,7 @@ def test_open_source_entry_check_reads_the_claim_thread(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the entry check asks for the thread, which is where a claim is written @@ -1515,7 +1515,7 @@ def test_open_source_template_empty_recon_names_the_extension_surface(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) assert "no gap inside the project's extension surface" in out, "空 Recon 结论须含扩展面阅读" @@ -1546,7 +1546,7 @@ def test_open_source_template_reads_the_extension_surface(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/os", source_dir="/tmp/os", session_id="s1", timestamp="20260824", - task={"role": "committer", "project": "aitokenpool"}, + task={"role": "committer", "agent": "aitokenpool"}, project={}, git_path="git", gh_path="gh", ) # 1) the section exists and says what a contribution through that layer even is @@ -1579,7 +1579,7 @@ def test_promote_template_learn_latest_state_04(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260814", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1615,7 +1615,7 @@ def test_promote_template_homework_first_dehardening(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260815", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1655,7 +1655,7 @@ def test_promote_template_direct_cdp_rule(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260825", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1683,7 +1683,7 @@ def test_promote_template_registration_blog_sections(): instance_id="test", host_name="host", repo_url="https://github.com/x/y.git", owner="x", repo="y", local_source="/tmp/pm", source_dir="/tmp/pm", session_id="s1", timestamp="20260815", - task={"project": "aitokenpool"}, + task={"agent": "aitokenpool"}, project={"path": "/tmp/proj", "name": "aitokenpool", "description": "d"}, git_path="git", gh_path="gh", ) @@ -1720,7 +1720,7 @@ def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): try: handler = make_handler( name=name, - config={"project": project} if project else {}, + config={"agent": project} if project else {}, interval=60, ) finally: @@ -1745,7 +1745,7 @@ def test_a_record_deleted_from_an_existing_file_stays_deleted(tmp_path): tasks_yml = tmp_path / "tasks.yml" tasks_yml.write_text(yaml.safe_dump([ - {"name": "other", "type": "evolution", "config": {"project": "other"}, "enabled": True}, + {"name": "other", "type": "evolution", "config": {"agent": "other"}, "enabled": True}, ])) sched = TaskScheduler(InstanceIdentity()) @@ -2703,7 +2703,7 @@ def test_connect_backoff_zero_failures_returns_interval(): from emrg.server.scheduler import TaskHandler handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=60, + name="emrg-task", config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) handler._connect_failures = 0 @@ -2715,7 +2715,7 @@ def test_connect_backoff_grows_exponentially_capped(tmp_path): from emrg.server.scheduler import TaskHandler handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=60, + name="emrg-task", config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) # interval=60s: 2^1=2 → 120s; 2^2=4 → 240s; 2^3=8 → 480s; 2^4=16 → 960s → capped 600s @@ -2732,7 +2732,7 @@ def test_connect_backoff_floor_30s_for_small_interval(tmp_path): from emrg.server.scheduler import TaskHandler handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=10, + name="emrg-task", config={"agent": "emrg"}, interval=10, identity=InstanceIdentity(), ) handler._connect_failures = 2 @@ -2888,7 +2888,7 @@ def slow_status(): sched = mod.TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "slow", "type": "evolution", "config": {"project": "emrg"}, "enabled": True}, + {"name": "slow", "type": "evolution", "config": {"agent": "emrg"}, "enabled": True}, ])) sched._live = {"slow": handler} with caplog.at_level(logging.WARNING, logger="emrg.server.scheduler"): @@ -2915,7 +2915,7 @@ def test_list_tasks_reads_the_record_for_the_declared_fields(tmp_path): sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ {"name": "journal", "type": "journal", - "config": {"project": "sci", "repo": "argszero/sci"}, + "config": {"agent": "sci", "repo": "argszero/sci"}, "interval": 3600, "enabled": False, "sandbox": "read-only"}, ])) @@ -2926,7 +2926,7 @@ def test_list_tasks_reads_the_record_for_the_declared_fields(tmp_path): assert row["name"] == "journal" assert row["type"] == "journal" assert row["enabled"] is False - assert row["config"] == {"project": "sci", "repo": "argszero/sci"} + assert row["config"] == {"agent": "sci", "repo": "argszero/sci"} assert row["sandbox"] == "read-only" assert row["running"] is False, "no handler is live for it yet" assert row["interval"] == 3600 @@ -2943,8 +2943,8 @@ def test_list_tasks_shows_a_deleted_task_gone_at_once(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._tasks_file.write_text(yaml.safe_dump([ - {"name": "keep", "type": "evolution", "config": {"project": "emrg"}}, - {"name": "drop", "type": "evolution", "config": {"project": "emrg"}}, + {"name": "keep", "type": "evolution", "config": {"agent": "emrg"}}, + {"name": "drop", "type": "evolution", "config": {"agent": "emrg"}}, ])) assert [t["name"] for t in sched.list_tasks()] == ["keep", "drop"] @@ -3000,7 +3000,7 @@ def test_task_create_and_duplicate(tmp_path): sched._tasks_file = tasks_file ok, task = sched.task_create("daily", "evolution", "mem", 300, repo="acme/x") assert ok and task["name"] == "daily" and task["interval"] == 300 - assert task["config"] == {"project": "mem", "repo": "acme/x"} + assert task["config"] == {"agent": "mem", "repo": "acme/x"} saved = yaml.safe_load(tasks_file.read_text(encoding="utf-8")) assert any(t["name"] == "daily" for t in saved) ok, err = sched.task_create("daily", "evolution", "emrg", 60) @@ -3033,7 +3033,7 @@ def test_a_record_the_file_carries_runs_even_with_uppercase_in_its_name(tmp_path "name": "OfficeCLI-opensource-task", "type": "open-source", "sandbox": "workspace-write", - "config": {"project": "OfficeCLI", "role": "contributor"}, + "config": {"agent": "OfficeCLI", "role": "contributor"}, "interval": 3600, "enabled": True, } @@ -3143,8 +3143,8 @@ def test_reconcile_starts_what_is_missing_and_cancels_nothing(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 300, "enabled": True}, - {"name": "b", "type": "evolution", "config": {"project": "mem"}, "interval": 300, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 300, "enabled": True}, + {"name": "b", "type": "evolution", "config": {"agent": "mem"}, "interval": 300, "enabled": True}, ]) async def _run(): @@ -3152,8 +3152,8 @@ async def _run(): running_a = sched._live["a"] # b is deleted and c added; a's interval changes. sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 900, "enabled": True}, - {"name": "c", "type": "evolution", "config": {"project": "mem"}, "interval": 900, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 900, "enabled": True}, + {"name": "c", "type": "evolution", "config": {"agent": "mem"}, "interval": 900, "enabled": True}, ]) second = sched.reconcile() # Nothing has yielded yet, so no handler has woken: this is the live set @@ -3190,7 +3190,7 @@ def test_reconcile_is_idempotent(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 300, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 300, "enabled": True}, ]) async def _run(): @@ -3220,7 +3220,7 @@ def test_reconcile_changes_nothing_when_the_table_is_unreadable(tmp_path): sched = TaskScheduler(InstanceIdentity()) sched._tasks_file = tmp_path / "tasks.yml" sched._write_table([ - {"name": "a", "type": "evolution", "config": {"project": "emrg"}, "interval": 300, "enabled": True}, + {"name": "a", "type": "evolution", "config": {"agent": "emrg"}, "interval": 300, "enabled": True}, ]) async def _run(): @@ -3361,10 +3361,10 @@ def test_evolution_template_renders_dual_project_match(): instance_id="test", host_name="host", repo_url="https://github.com/argszero/emrg.git", owner="argszero", repo="emrg", local_source="/tmp/evo", source_dir="/tmp/evo", session_id="s1", timestamp="20260817", - task={"role": "committer", "project": "emrg"}, + task={"role": "committer", "agent": "emrg"}, project={}, git_path="git", gh_path="gh", ) - assert "emrg" in out, "task.project 值应渲染" + assert "emrg" in out, "task.agent 值应渲染" assert "argszero/emrg" in out, "owner/repo 形式应渲染" assert "ignore rants without a `project` field entirely" in out @@ -3388,7 +3388,7 @@ def test_sandbox_resolution_unified_default_rule(): # no name-based defaults: emrg-task no longer implies workspace-write # via the old special case — it is the unified default now handler = make_handler( - name="emrg-task", config={"project": "emrg"}, interval=60, + name="emrg-task", config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) assert handler._sandbox == "workspace-write" diff --git a/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py b/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py index 4883d1bad..6d65449a7 100644 --- a/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py +++ b/tests/test_scheduler_and_llm_logs_apply_the_credential_rule.py @@ -207,7 +207,7 @@ def _handler(tmp_path, monkeypatch, name="emrg-task"): monkeypatch.setattr(sched_mod, "config_dir", lambda: tmp_path) return make_handler( name=name, - config={"project": "emrg"}, + config={"agent": "emrg"}, interval=60, identity=InstanceIdentity(), ) diff --git a/tests/test_task_config_key_migration.py b/tests/test_task_config_key_migration.py new file mode 100644 index 000000000..870e74c6c --- /dev/null +++ b/tests/test_task_config_key_migration.py @@ -0,0 +1,153 @@ +"""A task record's agent key: ``config.project`` → ``config.agent`` (issue #2531). + +The migration moves a key in a file a host's daemon reads, so these tests hold two +separate things: that the move is faithful (nothing else travels, order included), +and that **only the daemon's process entry** performs it. The second matters more +than the first — a migration reachable from any path the suite drives would rewrite a +host's live ``tasks.yml`` under a daemon that is still reading the old key. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from emrg.server import scheduler as mod +from emrg.server.scheduler import migrate_task_config_keys, read_table + +#: One table in the pre-rename shape, as a host's file carries it: the agent name +#: sits under `config.project` and nowhere else, and the surrounding fields are the +#: ones a real record has. +PRE_RENAME_SHAPE = [ + {"name": "emrg-task", "type": "evolution", "config": {"project": "emrg"}, + "interval": 600, "enabled": True}, + {"name": "journal-editor", "type": "journal", "config": {"project": "silicon-science-cs"}, + "interval": 1800, "enabled": True}, + {"name": "feishu-inbox", "type": "feishu", "config": {"project": "mem"}, + "interval": 300, "enabled": True}, +] + + +def _write(path: Path, records: list[dict]) -> None: + path.write_text( + yaml.safe_dump(records, sort_keys=False, allow_unicode=True), encoding="utf-8" + ) + + +def test_the_key_moves_and_nothing_else_travels(tmp_path): + """The value arrives unchanged, at the key's own position, with the record intact.""" + path = tmp_path / "tasks.yml" + _write(path, [{ + "name": "a", "type": "evolution", + "config": {"project": "emrg", "repo": "acme/x", "role": "author"}, + "interval": 600, "enabled": True, "schedule": None, + }]) + + assert migrate_task_config_keys(path) == 1 + + record = yaml.safe_load(path.read_text(encoding="utf-8"))[0] + assert record["config"] == {"agent": "emrg", "repo": "acme/x", "role": "author"} + assert list(record["config"]) == ["agent", "repo", "role"], "the key keeps its place" + assert record["interval"] == 600 + assert record["enabled"] is True + assert "project" not in record["config"] + + +def test_every_record_keeps_its_own_agent(tmp_path): + """One record per task: no record may end up wearing another's name.""" + path = tmp_path / "tasks.yml" + _write(path, PRE_RENAME_SHAPE) + expected = [r["config"]["project"] for r in PRE_RENAME_SHAPE] + + assert migrate_task_config_keys(path) == len(PRE_RENAME_SHAPE) + + migrated = read_table(path) + assert [r["config"]["agent"] for r in migrated] == expected + assert [r["name"] for r in migrated] == [r["name"] for r in PRE_RENAME_SHAPE] + assert [r["interval"] for r in migrated] == [r["interval"] for r in PRE_RENAME_SHAPE] + assert all("project" not in r["config"] for r in migrated) + + +def test_a_second_call_is_a_no_op(tmp_path): + """The migration is idempotent, so a restart does not rewrite the file again.""" + path = tmp_path / "tasks.yml" + _write(path, PRE_RENAME_SHAPE) + assert migrate_task_config_keys(path) == len(PRE_RENAME_SHAPE) + before = path.read_bytes() + + assert migrate_task_config_keys(path) == 0 + assert path.read_bytes() == before + + +def test_a_record_already_carrying_agent_is_left_alone(tmp_path): + path = tmp_path / "tasks.yml" + _write(path, [{"name": "a", "type": "evolution", "config": {"agent": "emrg"}, + "interval": 600, "enabled": True}]) + before = path.read_bytes() + + assert migrate_task_config_keys(path) == 0 + assert path.read_bytes() == before + + +def test_a_stale_project_key_is_dropped_rather_than_duplicated(tmp_path): + """Both keys present: `agent` governs, and the old word does not survive as residue.""" + path = tmp_path / "tasks.yml" + _write(path, [{"name": "a", "type": "evolution", + "config": {"agent": "correct", "project": "stale"}, + "interval": 600, "enabled": True}]) + + assert migrate_task_config_keys(path) == 1 + record = yaml.safe_load(path.read_text(encoding="utf-8"))[0] + assert record["config"] == {"agent": "correct"} + + +def test_a_missing_file_is_not_created(tmp_path): + """This moves an existing table; it never seeds one.""" + path = tmp_path / "tasks.yml" + assert migrate_task_config_keys(path) == 0 + assert not path.exists() + + +def test_an_unreadable_table_is_left_exactly_where_it_lies(tmp_path): + """A file this cannot parse is not a file to rewrite from a half-understood read.""" + path = tmp_path / "tasks.yml" + path.write_text("- name: a\n config: [unbalanced\n", encoding="utf-8") + before = path.read_bytes() + + assert migrate_task_config_keys(path) == 0 + assert path.read_bytes() == before + + +def test_the_reader_never_writes(tmp_path): + """`read_table` answers the record as written — old key and all — and touches nothing. + + This is the property that keeps a suite run off a host's live table: reading a + record must never be the act that renames it. + """ + path = tmp_path / "tasks.yml" + _write(path, PRE_RENAME_SHAPE) + before = path.read_bytes() + before_mtime = path.stat().st_mtime_ns + + records = read_table(path) + + assert records[0]["config"] == {"project": "emrg"}, "the reader reports what is written" + assert path.read_bytes() == before + assert path.stat().st_mtime_ns == before_mtime + + +def test_only_the_daemon_process_entry_calls_the_migration(): + """The migration has one reachable call site, and it is the one no test drives. + + A second call site — a scheduler method, a loader, a `read_table` — would make the + rename happen wherever that path ran, which is the shape that rewrites a host's + live file from inside a test run. + """ + root = Path(mod.__file__).resolve().parents[2] + hits = [] + for path in sorted((root / "emrg").rglob("*.py")): + if "migrate_task_config_keys()" in path.read_text(encoding="utf-8"): + hits.append(str(path.relative_to(root))) + + assert hits == ["emrg/server/__main__.py"] diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index ba6d16a55..3e2eb58b0 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -3170,8 +3170,8 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): - calls.update(name=name, task_type=task_type, project=project) + def fake_create(name, task_type, agent, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): + calls.update(name=name, task_type=task_type, agent=agent) return True, {"name": name, "type": task_type} server._scheduler = self._mock_scheduler(create_side_effect=fake_create) @@ -3179,12 +3179,12 @@ def fake_create(name, task_type, project, interval=None, enabled=True, repo=None try: resp = await self._cmd(ws, { "type": "task_create", "name": "daily", - "task_type": "evolution", "project": "emrg", + "task_type": "evolution", "agent": "emrg", }) assert resp["type"] == "task_result" assert resp.get("ok") is True assert calls["task_type"] == "evolution", "daemon must read task_type" - assert calls["project"] == "emrg" + assert calls["agent"] == "emrg" finally: await ws.close() finally: @@ -3200,15 +3200,15 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): - calls.update(name=name, task_type=task_type, project=project) + def fake_create(name, task_type, agent, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): + calls.update(name=name, task_type=task_type, agent=agent) return True, {"name": name, "type": task_type} server._scheduler = self._mock_scheduler(create_side_effect=fake_create) ws = await connect_to_server() try: resp = await self._cmd(ws, { - "type": "task_create", "name": "daily", "project": "emrg", + "type": "task_create", "name": "daily", "agent": "emrg", }) assert resp["type"] == "task_result" assert calls["task_type"] == "", "must not fall back to wire type 'task_create'" @@ -3231,7 +3231,7 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, + def fake_create(name, task_type, agent, interval=None, enabled=True, repo=None, description=None, sandbox=None, schedule=None): calls.update(interval=interval, schedule=schedule) return True, {"name": name, "type": task_type} @@ -3244,7 +3244,7 @@ def fake_create(name, task_type, project, interval=None, enabled=True, try: resp = await self._cmd(ws, { "type": "task_create", "name": "daily", - "task_type": "evolution", "project": "emrg", + "task_type": "evolution", "agent": "emrg", "interval": 10800, "schedule": window, }) assert resp["type"] == "task_result" From 3abbbec80e05625a6e12e11b3e38677277f8dfa7 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 16:42:54 +0800 Subject: [PATCH 02/11] emrg: a rant record names its agent under agent, migrated in place The record's target field moves from project to agent across the store, the daemon's rant wire, the submit_rant tool and both clients, with a once-at-startup in-place migration that rewrites raw JSON (the normalizer keys on the canonical field list, so a row read through it would drop the old value silently). --- .../src/components/RantDialog.test.tsx | 8 +- .../renderer/src/components/Shell.test.tsx | 8 +- emrg/gui/renderer/src/components/Shell.tsx | 12 +- .../src/components/WorkspaceView.test.tsx | 2 +- .../renderer/src/components/WorkspaceView.tsx | 6 +- emrg/gui/renderer/src/lib/workspaceView.ts | 2 +- emrg/server/daemon.py | 8 +- emrg/server/promote_prompt.md | 6 +- emrg/server/prompts/system.j2 | 2 +- emrg/server/rants.py | 27 ++-- emrg/tools/submit_rant_tool.py | 48 ++++---- scripts/review-queue.py | 7 +- tests/test_review_queue.py | 10 +- tests/test_submit_rant_tool.py | 116 +++++++++--------- 14 files changed, 136 insertions(+), 126 deletions(-) diff --git a/emrg/gui/renderer/src/components/RantDialog.test.tsx b/emrg/gui/renderer/src/components/RantDialog.test.tsx index a0f6d4b35..75f279c39 100644 --- a/emrg/gui/renderer/src/components/RantDialog.test.tsx +++ b/emrg/gui/renderer/src/components/RantDialog.test.tsx @@ -7,7 +7,7 @@ import type { ProjectRec } from "../lib/workspaceView"; /** * RantDialog.test.tsx — Batch 5 slice 8:Rant 提交对话框。 * 镜像 vanilla openRantForm/submitRantForm 语义:打开清空 + 聚焦、消息必填、 - * 项目下拉(已注册项目)、提交 payload {message, project}。 + * 项目下拉(已注册项目)、提交 payload {message, agent}。 */ function wrapper(node: React.ReactNode) { @@ -41,14 +41,14 @@ describe("RantDialog", () => { expect(onSubmit).not.toHaveBeenCalled(); }); - it("提交 payload {message, project}(选项目)", async () => { + it("提交 payload {message, agent}(选项目)", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render(wrapper()); fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "侧栏太宽了" } }); fireEvent.change(screen.getByTestId("rant-project"), { target: { value: "emrg" } }); fireEvent.click(screen.getByTestId("rant-submit")); await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - expect(onSubmit.mock.calls[0][0]).toEqual({ message: "侧栏太宽了", project: "emrg" }); + expect(onSubmit.mock.calls[0][0]).toEqual({ message: "侧栏太宽了", agent: "emrg" }); }); it("项目选填:留空提交 project=''", async () => { @@ -57,7 +57,7 @@ describe("RantDialog", () => { fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "hello" } }); fireEvent.click(screen.getByTestId("rant-submit")); await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - expect(onSubmit.mock.calls[0][0]).toEqual({ message: "hello", project: "" }); + expect(onSubmit.mock.calls[0][0]).toEqual({ message: "hello", agent: "" }); }); it("closed (open=false) → 不渲染", () => { diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 5fe787782..6cc907ff3 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -27,7 +27,7 @@ function mockEmrg() { const compactSession = vi.fn().mockResolvedValue({ ok: true }); const listProjects = vi.fn().mockResolvedValue([{ name: "emrg", path: "/p/emrg" }]); const listTasks = vi.fn().mockResolvedValue([{ name: "evo", type: "evolution", enabled: true, session_id: "evo", project_path: "/p/emrg" }]); - const listRants = vi.fn().mockResolvedValue([{ timestamp: "2026-08-26T12:00:00+08:00", project: "emrg", status: "pending", message: "x" }]); + const listRants = vi.fn().mockResolvedValue([{ timestamp: "2026-08-26T12:00:00+08:00", agent: "emrg", status: "pending", message: "x" }]); const listMemories = vi.fn().mockResolvedValue([]); const listSkills = vi.fn().mockResolvedValue([]); const listHistory = vi.fn().mockResolvedValue({ messages: [] }); @@ -695,7 +695,7 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { // 因此断言的是真渲染产物:#### 【现象】 →

。 const m = mockEmrg(); m.listRants.mockResolvedValue([ - { timestamp: "2026-09-30T09:06:16+08:00", project: "emrg", status: "pending", message: "【现象】\n**加粗**正文" }, + { timestamp: "2026-09-30T09:06:16+08:00", agent: "emrg", status: "pending", message: "【现象】\n**加粗**正文" }, ]); render(wrapper()); await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); @@ -847,7 +847,7 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "测试 rant" } }); fireEvent.change(screen.getByTestId("rant-project"), { target: { value: "emrg" } }); fireEvent.click(screen.getByTestId("rant-submit")); - await waitFor(() => expect(m.sendRant).toHaveBeenCalledWith({ message: "测试 rant", project: "emrg" })); + await waitFor(() => expect(m.sendRant).toHaveBeenCalledWith({ message: "测试 rant", agent: "emrg" })); await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2)); expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument(); }); @@ -994,7 +994,7 @@ describe("Shell — 会话信息行 + /指令接线(rant 2026-09-01T20:16:55 / const input = await screen.findByTestId("composer-input"); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); await waitFor(() => - expect(m.sendRant).toHaveBeenCalledWith({ message: "hello world", project: "emrg" }), + expect(m.sendRant).toHaveBeenCalledWith({ message: "hello world", agent: "emrg" }), ); }); diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 8015cf8ae..a87e1555f 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -76,7 +76,7 @@ interface WorkspaceBridge { taskLimits?(): Promise<{ minInterval?: number }>; taskDelete(p: { name: string }): Promise; taskTemplateList(): Promise<{ name: string }[]>; - sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>; + sendRant(p: { message: string; agent?: string }): Promise<{ ok?: boolean; count?: number }>; /** * 升级横幅重启(rant 2026-09-01T20:09:41):调 emrg:restartDaemon —— 完整 stop * 链(TUI→daemon,--skip-gui 跳过 GUI 防自杀)→ GUI 自己 relaunch → 新 GUI @@ -428,7 +428,7 @@ export function Shell() { }); } - async function submitRant(payload: { message: string; project: string }) { + async function submitRant(payload: { message: string; agent: string }) { const b = wsBridge(); if (!b?.sendRant) return; try { @@ -660,20 +660,20 @@ export function Shell() { } } - /** /rant:有参 → 直发(/@project 前缀指定项目,对齐 TUI);无参/空内容 → 对话框。 */ + /** /rant:有参 → 直发(/@name 前缀指定目标,对齐 TUI);无参/空内容 → 对话框。 */ async function handleRantCommand(args: string[]) { const first = args[0] ?? ""; - let project = ""; + let agent = ""; let message = args.join(" "); if (first.startsWith("@")) { - project = first.slice(1); + agent = first.slice(1); message = args.slice(1).join(" "); } if (!message.trim()) { await newRant(); return; } - await submitRant({ message, project }); + await submitRant({ message, agent }); } /** /trigger:有参 → 直发任务;无参 → 任务面板。 */ diff --git a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx index c727706f3..0e0a47bd7 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.test.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.test.tsx @@ -35,7 +35,7 @@ const task = (name: string, over: Partial = {}): TaskRec => ({ const rant = (over: Partial = {}): RantRec => ({ timestamp: "2026-08-26T12:16:48+08:00", - project: "emrg", + agent: "emrg", status: "pending", progress: null, message: "## 测试 rant\n正文", diff --git a/emrg/gui/renderer/src/components/WorkspaceView.tsx b/emrg/gui/renderer/src/components/WorkspaceView.tsx index 53fa9eeca..bca8ccf9d 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.tsx @@ -453,7 +453,7 @@ function RantList({ return (
{rants.map((r) => { - const key = String(r.timestamp || r.project || r.message || Math.random()); + const key = String(r.timestamp || r.agent || r.message || Math.random()); const ts = rantTimestamp(r.timestamp); const st = rantStatusMeta(r.status, t); const firstLine = rantFirstLine(r.message); @@ -466,14 +466,14 @@ function RantList({ onClick={() => onToggle(key)} > {ts} - {r.project || "—"} + {r.agent || "—"} {st.text} {r.progress ? String(r.progress) : "—"} {firstLine}
{isExpanded && (
-
{ts} · {r.project || "—"} · {st.text}
+
{ts} · {r.agent || "—"} · {st.text}
{t("rants.colContent")}
{/* markdown 渲染(rant 2026-09-30T09:06:16 / #1763):preprocessRantMarkdown 的 diff --git a/emrg/gui/renderer/src/lib/workspaceView.ts b/emrg/gui/renderer/src/lib/workspaceView.ts index a9ed05b28..d936c837e 100644 --- a/emrg/gui/renderer/src/lib/workspaceView.ts +++ b/emrg/gui/renderer/src/lib/workspaceView.ts @@ -11,7 +11,7 @@ export type RantFilter = "" | "pending" | "in_progress" | "completed"; export interface RantRec { timestamp?: string; - project?: string; + agent?: string; status?: string; progress?: string | null; message?: string; diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 84c1ae449..9a3271fcf 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -3222,17 +3222,17 @@ async def _process_message( await self._send(ws, {"error": "rant requires a message"}) return - # Optional project targeting (multi-project support) - project = msg.get("project", "").strip() + # Optional agent targeting (multi-agent support) + agent = msg.get("agent", "").strip() # Shared write logic (rant 2026-08-17T11:51:59): daemon ``rant`` # command and the submit_rant tool use the same append_rant, so # the file format / sort / daemon-authoritative timestamp stay # consistent no matter which path recorded the rant. - count = append_rant(self._rants_log, rant_message, project) + count = append_rant(self._rants_log, rant_message, agent) logger.info("rant recorded (%d total)%s: %s", - count, f" project={project}" if project else "", redact_prefix(rant_message, 100)) + count, f" agent={agent}" if agent else "", redact_prefix(rant_message, 100)) await self._send(ws, {"ok": True, "count": count}) elif msg_type == "list_rants": diff --git a/emrg/server/promote_prompt.md b/emrg/server/promote_prompt.md index 6abe05283..d477e008b 100644 --- a/emrg/server/promote_prompt.md +++ b/emrg/server/promote_prompt.md @@ -295,7 +295,7 @@ Promotion is two-way. Community feedback gathered during promotion — **proacti **Writing rules** — one tool call, and never a hand-written rewrite of the file: ``` -submit_rant(action="submit", project="{{ project.name }}", +submit_rant(action="submit", agent="{{ project.name }}", message="community feedback ( ): ") ``` @@ -307,8 +307,8 @@ here, because a second copy of a rule is a copy that can disagree. So there is nothing for this prompt to write by hand — and no reason to open the file at all, not even to read it. -- `project` = the promoted project's registered short name (as in `~/.emrg/agents.yml`); the rant is then handled by that project's evolution task. The tool warns when the name is not registered. -- Deduplicate before submitting: `submit_rant(action="list", project="{{ project.name }}")` and skip feedback that is already queued. +- `agent` = the promoted project's registered short name (as in `~/.emrg/agents.yml`); the rant is then handled by that project's evolution task. The tool warns when the name is not registered. +- Deduplicate before submitting: `submit_rant(action="list", agent="{{ project.name }}")` and skip feedback that is already queued. - Each message notes the source (channel + link) so the evolution task can trace back. **Also file a public GitHub issue on the promoted project** (PR #932, rant 2026-08-22T08:14:31) — a rant is an internal queue (no issue number, not community-visible); a public issue is transparent, traceable, and lets the community participate. For **valuable feedback** (same table above — feature request / bug report / negative experience / new problem / inspiration): diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index 854e6f3e1..a51167ee0 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -86,7 +86,7 @@ Session `{{ session.id }}` lives in `{{ session.dir_path }}/`. A rant (吐槽) is the host's feedback — a complaint, a bug, a feature request, a suggestion about EMRG or about any registered project. It arrives as ordinary conversation ("this is bad", "there's a bug", "it should…", "why not…"), so there is no mode to enter and no prefix to wait for. -- **Ask which project and what change** when that is unclear: `submit_rant` needs a project. +- **Ask which agent and what change** when that is unclear: `submit_rant` needs an agent name — the short `name` registered in `agents.yml`. - **Polish it** into a description someone could act on, and show that description. - **Call `submit_rant` only once the host agrees** — the call is the confirmation. If the host says never mind, do not call it. diff --git a/emrg/server/rants.py b/emrg/server/rants.py index 7a8270375..e9f97735b 100644 --- a/emrg/server/rants.py +++ b/emrg/server/rants.py @@ -22,9 +22,9 @@ from emrg.memory import NotARegularFile from emrg.tools.base import non_regular_kind -# Canonical field order: timestamp → project → status → progress → completed → message -# (project right after timestamp per user feedback; message last) -_RANT_FIELDS = ("timestamp", "project", "status", "progress", "completed", "message") +# Canonical field order: timestamp → agent → status → progress → completed → message +# (agent right after timestamp per user feedback; message last) +_RANT_FIELDS = ("timestamp", "agent", "status", "progress", "completed", "message") # State machine: pending → in_progress → completed (never skip a level) _ALLOWED_STATUS_TRANSITIONS = { @@ -38,9 +38,16 @@ def _normalize_rant(raw) -> dict | None: """Normalize one parsed line to the canonical 6-field dict. Accepts dict rows (canonical). Converts legacy array rows - ``[timestamp, project, status, progress, completed, message]`` back to + ``[timestamp, agent, status, progress, completed, message]`` back to dicts (the 2026-08-18 format-drift incident). Unknown field shapes / corrupt rows return None and are skipped. + + The array form maps by **position**, and the rename left that position where it was: + the second slot still holds the target's name, so a row written before the rename + converts to ``agent`` with no move of its own. Nothing here reads a dict row's + ``project`` key — a table still carrying it is moved by + :func:`migrate_rant_agent_field` before any reader sees it, and a fallback here + would keep both spellings alive, which is what the move exists to end. """ if isinstance(raw, dict): return {k: raw.get(k) for k in _RANT_FIELDS} @@ -130,13 +137,13 @@ def _write_rants(rants_log: Path, rants: list[dict]) -> None: f.write(json.dumps(r, ensure_ascii=False) + "\n") -def append_rant(rants_log: Path, message: str, project: str = "") -> int: +def append_rant(rants_log: Path, message: str, agent: str = "") -> int: """Append a rant entry to ``rants_log``, sorted by timestamp. Args: rants_log: Path to rants.jsonl (e.g. ``~/.emrg/rants.jsonl``). message: The rant body (already user-confirmed / polished). - project: Optional target project name (empty = EMRG itself). + agent: Optional target agent name (empty = EMRG itself). Returns: The new total rant count. @@ -148,7 +155,7 @@ def append_rant(rants_log: Path, message: str, project: str = "") -> int: # consistent regardless of which client submitted the rant. entry: dict = { "timestamp": datetime.now().astimezone().isoformat(), - "project": project, + "agent": agent, "status": "pending", "progress": None, "completed": None, @@ -166,13 +173,13 @@ def append_rant(rants_log: Path, message: str, project: str = "") -> int: def list_rants( rants_log: Path, status: str | None = None, - project: str | None = None, + agent: str | None = None, ) -> list[dict]: - """Return rant entries, optionally filtered by status and/or project.""" + """Return rant entries, optionally filtered by status and/or agent.""" return [ r for r in _read_rants(rants_log) if (status is None or r.get("status") == status) - and (project is None or r.get("project") == project) + and (agent is None or r.get("agent") == agent) ] diff --git a/emrg/tools/submit_rant_tool.py b/emrg/tools/submit_rant_tool.py index 588136fbe..44a8e3b87 100644 --- a/emrg/tools/submit_rant_tool.py +++ b/emrg/tools/submit_rant_tool.py @@ -126,8 +126,8 @@ def definition(self) -> ToolDefinition: "on it. Must obtain explicit user consent before calling — " "clarify the target and polish the text, show the user the " "result, and only then call. " - "**action=list**: list rants (optional status/project/full " - "filters). Every rant gets a row carrying timestamp/project/" + "**action=list**: list rants (optional status/agent/full " + "filters). Every rant gets a row carrying timestamp/agent/" "status/a progress excerpt/completed and a message excerpt — " "the scan view, and the default for an unfiltered read, " "because the queue is scanned to decide *which* rant to act " @@ -158,14 +158,14 @@ def definition(self) -> ToolDefinition: "Which action to perform. Default: submit." ), }, - "project": { + "agent": { "type": "string", "description": ( - "REQUIRED for submit — target project name = the " + "REQUIRED for submit — the target agent's name = the " "short `name` registered in ~/.emrg/agents.yml " "(e.g. 'emrg', 'aitokenpool') — NOT the GitHub " "owner/repo form (rant 2026-08-24T10:54:04). If " - "you cannot determine which project the rant " + "you cannot determine which agent the rant " "targets, ask the user before calling. Optional " "filter for list." ), @@ -276,35 +276,35 @@ def _execute_submit(self, arguments: dict) -> ToolResult: content="Error: submit_rant requires a message", error=True, ) - project = str(arguments.get("project", "") or "").strip() - if not project: + agent = str(arguments.get("agent", "") or "").strip() + if not agent: return ToolResult( name="submit_rant", content=( - "Error: project is required — ask the user which project " + "Error: agent is required — ask the user which agent " "this rant targets before submitting" ), error=True, ) try: - count = append_rant(self._rants_log(), message, project) + count = append_rant(self._rants_log(), message, agent) except Exception as e: # noqa: BLE001 — tool errors must never crash the loop return ToolResult( name="submit_rant", content=f"Error: failed to record rant: {e}", error=True, ) - target = f" ({project})" if project else "" - # Rant 2026-08-24T10:54:04: project must be the short name registered + target = f" ({agent})" if agent else "" + # Rant 2026-08-24T10:54:04: the name must be the short one registered # in ~/.emrg/agents.yml — not the GitHub owner/repo form. When the # given name is not registered, append a non-blocking warning with the # registered candidates so a copy-pasted wrong name is caught at # submit time instead of requiring a manual re-submit + void. - registered = self._registered_project_names() - if registered and project not in registered: + registered = self._registered_agent_names() + if registered and agent not in registered: candidates = " | ".join(sorted(registered)) warning = ( - f"\n⚠ project {project!r} is not a registered name in " + f"\n⚠ agent {agent!r} is not a registered name in " f"~/.emrg/agents.yml (registered: {candidates}) — use the " f"short `name` (e.g. 'emrg' or 'aitokenpool')." ) @@ -315,11 +315,11 @@ def _execute_submit(self, arguments: dict) -> ToolResult: content=f"Rant recorded{target}. Total rants: {count}.{warning}", ) - def _registered_project_names(self) -> list[str]: + def _registered_agent_names(self) -> list[str]: """Short names registered in ~/.emrg/agents.yml (config dir). - The daemon registers projects there on first use; the list is the - authoritative source for the rant ``project`` field. Any read error + The daemon registers a directory there on first use; the list is the + authoritative source for the rant ``agent`` field. Any read error degrades to "no candidates" (validation is advisory, never blocking). The subject's kind is asked before it is opened: `exists()` is true for a FIFO, and @@ -353,11 +353,11 @@ def _registered_project_names(self) -> list[str]: def _execute_list(self, arguments: dict) -> ToolResult: status = arguments.get("status") - project = arguments.get("project") + agent = arguments.get("agent") if status is not None: status = str(status).strip() or None - if project is not None: - project = str(project).strip() or None + if agent is not None: + agent = str(agent).strip() or None # Read by the shared reader, not by truthiness — issue #1935's family, which # #2294's schema-derived sweep extends to every parameter a tool declares # `"type": "boolean"`. This one was a hand-rolled spelling list, so nothing was @@ -371,7 +371,7 @@ def _execute_list(self, arguments: dict) -> ToolResult: if refusal is not None: return ToolResult(name="submit_rant", content=refusal, error=True) try: - rants = list_rants(self._rants_log(), status=status, project=project) + rants = list_rants(self._rants_log(), status=status, agent=agent) except Exception as e: # noqa: BLE001 return ToolResult( name="submit_rant", @@ -384,7 +384,7 @@ def _execute_list(self, arguments: dict) -> ToolResult: content=( "No rants match." + (f" (status={status})" if status else "") - + (f" (project={project})" if project else "") + + (f" (agent={agent})" if agent else "") ), ) # The message is the rant, and the row is the scan view of it. `[…][:100]` used to @@ -410,7 +410,7 @@ def _execute_list(self, arguments: dict) -> ToolResult: # 「就定 a + b(c 去掉)」 — item (a) is the scan row, item (b) the text on demand, and # rant 2026-10-15T12:35:03 item (b) writes that requirement out as # 「正文只在按 status 过滤或显式要求时给」 — the text comes when the caller filters by - # status or asks explicitly), or `full=True`. `project=` deliberately does **not** + # status or asks explicitly), or `full=True`. `agent=` deliberately does **not** # unlock it: it is the dedup filter `promote_prompt.md` uses, and on the queue # measured 2026-10-15 it selects 48 of 50 rows — it narrows the *subject*, not the # cost. Every withheld row names the call that returns its body, and the read closes @@ -433,7 +433,7 @@ def _execute_list(self, arguments: dict) -> ToolResult: summary = _excerpt(" ".join(message.split())) progress_row = _excerpt(" ".join(progress.split())) lines.append( - f"{r.get('timestamp')} | {r.get('project')} | " + f"{r.get('timestamp')} | {r.get('agent')} | " f"status={r.get('status')} | progress={progress_row} | " f"completed={r.get('completed')} | {summary}" ) diff --git a/scripts/review-queue.py b/scripts/review-queue.py index 6bbff14cc..4383abe63 100644 --- a/scripts/review-queue.py +++ b/scripts/review-queue.py @@ -625,7 +625,7 @@ def open_rant_rows(rants: str | None = None, repo: str = REPO) -> list[Rant]: for row in wanted if not named[str(row.get("timestamp", ""))] and not by_timestamp.get(str(row.get("timestamp", ""))) - and could_declare_here(str(row.get("project", "") or ""), repo) + and could_declare_here(str(row.get("agent", "") or ""), repo) ] if unplaced: since = min( @@ -680,7 +680,10 @@ def open_rant_rows(rants: str | None = None, repo: str = REPO) -> list[Rant]: issues=sorted(open_here), closed_issues=sorted(closed_here), unread_issues=sorted(unread_here), - project=str(row.get("project", "") or ""), + # The ledger's own key is `agent` (the record's target); the name + # this model keeps for the concept is its own, and the output rows below + # still label it `project`. + project=str(row.get("agent", "") or ""), ) ) return sorted(out, key=lambda rant: rant.timestamp, reverse=True) diff --git a/tests/test_review_queue.py b/tests/test_review_queue.py index 6ada22dfc..8be6016bf 100644 --- a/tests/test_review_queue.py +++ b/tests/test_review_queue.py @@ -2144,7 +2144,7 @@ def _links(monkeypatch, tool, rows, issues=(), path="/tmp/rants.jsonl", closed_i def _row(stamp, status="pending", message="a rant", progress=None): - row = {"timestamp": stamp, "status": status, "message": message, "project": "emrg"} + row = {"timestamp": stamp, "status": status, "message": message, "agent": "emrg"} if progress is not None: row["progress"] = progress return row @@ -2198,7 +2198,7 @@ def test_a_rants_project_is_carried_from_the_row_the_ledger_parsed(monkeypatch): [ _row("2026-09-30T09:17:54+08:00"), {"timestamp": "2026-09-30T09:30:16+08:00", "status": "pending", "message": "m"}, - {"timestamp": "2026-09-30T09:35:04+08:00", "status": "pending", "project": None}, + {"timestamp": "2026-09-30T09:35:04+08:00", "status": "pending", "agent": None}, ], ) @@ -2270,7 +2270,7 @@ def test_another_projects_rants_do_not_widen_the_closed_window(monkeypatch): [ _row(emrg_stamp), {"timestamp": other_stamp, "status": "pending", "message": "theirs", - "project": "silicon-science-cs"}, + "agent": "silicon-science-cs"}, ], ) @@ -2302,7 +2302,7 @@ def test_a_foreign_project_alone_spends_no_closed_reading(monkeypatch): monkeypatch, tool, [{"timestamp": "2026-09-11T23:51:35.442412+08:00", "status": "pending", - "message": "theirs", "project": "silicon-science-cs"}], + "message": "theirs", "agent": "silicon-science-cs"}], ) (rant,) = tool.open_rant_rows(None, tool.REPO) @@ -2333,7 +2333,7 @@ def test_the_two_spellings_of_this_project_both_match(monkeypatch): links = _links( monkeypatch, tool, [{"timestamp": stamp, "status": "pending", "message": "ours", - "project": tool.REPO}], + "agent": tool.REPO}], closed_issues=[(1807, f"Origin: rant {stamp}\n\nbody")], ) diff --git a/tests/test_submit_rant_tool.py b/tests/test_submit_rant_tool.py index 57a9a4715..764963b7f 100644 --- a/tests/test_submit_rant_tool.py +++ b/tests/test_submit_rant_tool.py @@ -41,7 +41,7 @@ def test_append_rant_writes_sorted_entry(tmp_path): rants_file.write_text( json.dumps({ "timestamp": older_ts, - "project": "emrg", + "agent": "emrg", "status": "completed", "progress": None, "completed": "2026-08-17T08:30:00+08:00", @@ -49,7 +49,7 @@ def test_append_rant_writes_sorted_entry(tmp_path): }, ensure_ascii=False) + "\n", encoding="utf-8", ) - count = append_rant(rants_file, "new feedback", project="argszero/aitokenpool") + count = append_rant(rants_file, "new feedback", agent="argszero/aitokenpool") assert count == 2 lines = rants_file.read_text(encoding="utf-8").strip().splitlines() @@ -58,11 +58,11 @@ def test_append_rant_writes_sorted_entry(tmp_path): # sorted by timestamp: older first, new last assert entries[0]["message"] == "older rant" assert entries[1]["message"] == "new feedback" - # field order: timestamp → project → status → progress → completed → message + # field order: timestamp → agent → status → progress → completed → message assert list(entries[1].keys()) == [ - "timestamp", "project", "status", "progress", "completed", "message", + "timestamp", "agent", "status", "progress", "completed", "message", ] - assert entries[1]["project"] == "argszero/aitokenpool" + assert entries[1]["agent"] == "argszero/aitokenpool" assert entries[1]["status"] == "pending" assert entries[1]["completed"] is None # daemon-authoritative tz-aware timestamp @@ -80,7 +80,7 @@ def test_append_rant_missing_file_and_corrupt_lines(tmp_path): entries = [json.loads(l) for l in rants_file.read_text(encoding="utf-8").strip().splitlines()] assert entries[0]["message"] == "hello" - assert entries[0]["project"] == "" + assert entries[0]["agent"] == "" def test_submit_rant_tool_writes_and_reports_count(tmp_path, monkeypatch): @@ -89,7 +89,7 @@ def test_submit_rant_tool_writes_and_reports_count(tmp_path, monkeypatch): tool = SubmitRantTool() result = __import__("asyncio").run(tool.execute( - {"message": "this feature is broken", "project": "argszero/aitokenpool"} + {"message": "this feature is broken", "agent": "argszero/aitokenpool"} )) assert result.error is False assert "Total rants: 1" in result.content @@ -111,7 +111,7 @@ def test_submit_rant_tool_requires_project(): tool = SubmitRantTool() result = __import__("asyncio").run(tool.execute({"message": "some complaint"})) assert result.error is True - assert "project is required" in result.content + assert "agent is required" in result.content assert "ask the user" in result.content @@ -127,7 +127,7 @@ def test_submit_rant_warns_on_unregistered_project(tmp_path, monkeypatch): ) tool = SubmitRantTool() result = __import__("asyncio").run(tool.execute( - {"message": "something is broken", "project": "argszero/aitokenpool"} + {"message": "something is broken", "agent": "argszero/aitokenpool"} )) assert result.error is False assert "Total rants: 1" in result.content @@ -138,7 +138,7 @@ def test_submit_rant_warns_on_unregistered_project(tmp_path, monkeypatch): # the rant still lands (non-blocking warning) lines = (tmp_path / "rants.jsonl").read_text(encoding="utf-8").strip().splitlines() assert len(lines) == 1 - assert json.loads(lines[0])["project"] == "argszero/aitokenpool" + assert json.loads(lines[0])["agent"] == "argszero/aitokenpool" def test_submit_rant_clean_on_registered_project(tmp_path, monkeypatch): @@ -150,7 +150,7 @@ def test_submit_rant_clean_on_registered_project(tmp_path, monkeypatch): ) tool = SubmitRantTool() result = __import__("asyncio").run(tool.execute( - {"message": "still broken", "project": "emrg"} + {"message": "still broken", "agent": "emrg"} )) assert result.error is False assert "Total rants: 1" in result.content @@ -163,11 +163,11 @@ def test_submit_rant_definition_exposes_consent_contract(): d = tool.definition() assert d.name == "submit_rant" assert "consent" in d.description.lower() or "confirm" in d.description.lower() - # action is the only statically-required param; submit's project/message + # action is the only statically-required param; submit's agent/message # are enforced in execute() (see test_submit_rant_tool_requires_*) assert "action" in d.parameters["required"] assert "message" in d.parameters["properties"] # documented - assert "project" in d.parameters["properties"] # documented since rant 12:03:13 + assert "agent" in d.parameters["properties"] # documented since rant 12:03:13 # rant 2026-08-19T10:35:24: static purpose removed → per-call intent required assert not hasattr(d, "purpose") assert "intent" in d.parameters["required"] @@ -218,49 +218,49 @@ def test_read_tolerates_legacy_array_rows(tmp_path): f.write_text( f'["{older}", "emrg", "pending", null, null, "array rant"]\n' "not-json\n" - f'{{"timestamp": "{newer}", "project": "emrg", ' + f'{{"timestamp": "{newer}", "agent": "emrg", ' '"status": "completed", "progress": null, ' f'"completed": "{newer}", "message": "dict rant", ' '"unknown": "ignored"}\n', encoding="utf-8", ) # append_rant must not crash on the mixed file and must normalize rows - count = append_rant(f, "new rant", project="emrg") + count = append_rant(f, "new rant", agent="emrg") assert count == 3 entries = [json.loads(l) for l in f.read_text(encoding="utf-8").strip().splitlines()] assert entries[0]["message"] == "array rant" - assert entries[0]["project"] == "emrg" + assert entries[0]["agent"] == "emrg" # dict rows keep the 6 canonical fields, unknown fields dropped assert list(entries[1].keys()) == [ - "timestamp", "project", "status", "progress", "completed", "message", + "timestamp", "agent", "status", "progress", "completed", "message", ] assert entries[1]["message"] == "dict rant" def test_list_rants_filters_by_status_and_project(tmp_path): f = _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "emrg pending"}, - {"timestamp": "2026-08-18T11:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T11:00:00+08:00", "agent": "emrg", "status": "completed", "progress": None, "completed": "2026-08-18T10:30:00+08:00", "message": "emrg done"}, - {"timestamp": "2026-08-18T12:00:00+08:00", "project": "aitokenpool", + {"timestamp": "2026-08-18T12:00:00+08:00", "agent": "aitokenpool", "status": "pending", "progress": None, "completed": None, "message": "atp pending"}, ]) assert len(list_rants(f)) == 3 assert len(list_rants(f, status="pending")) == 2 assert len(list_rants(f, status="completed")) == 1 - assert len(list_rants(f, project="emrg")) == 2 - assert len(list_rants(f, status="pending", project="emrg")) == 1 - assert len(list_rants(f, status="completed", project="aitokenpool")) == 0 + assert len(list_rants(f, agent="emrg")) == 2 + assert len(list_rants(f, status="pending", agent="emrg")) == 1 + assert len(list_rants(f, status="completed", agent="aitokenpool")) == 0 def test_update_rant_state_machine(tmp_path): f = _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "test rant"}, ]) @@ -283,7 +283,7 @@ def test_update_rant_state_machine(tmp_path): assert "invalid transition" in msg # pending → completed directly: invalid (no skipping) f2 = _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "test rant 2"}, ]) @@ -294,7 +294,7 @@ def test_update_rant_state_machine(tmp_path): def test_update_rant_unknown_timestamp_and_bad_status(tmp_path): f = _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "test rant"}, ]) @@ -319,7 +319,7 @@ def test_update_rant_refuses_a_completion_before_the_submission(tmp_path): later and equal are accepted. """ f = _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "in_progress", "progress": None, "completed": None, "message": "test rant"}, ]) @@ -357,7 +357,7 @@ def test_update_rant_refuses_a_completion_it_cannot_order(tmp_path): is naive against naive — an unorderable pair whose premise nothing has checked. """ stamp = "2026-08-18T10:00:00+08:00" - row = {"timestamp": stamp, "project": "emrg", "status": "in_progress", + row = {"timestamp": stamp, "agent": "emrg", "status": "in_progress", "progress": None, "completed": None, "message": "test rant"} f = _write_rant_lines(tmp_path, [dict(row)]) @@ -383,7 +383,7 @@ def test_the_auto_written_stamp_is_checked_too(tmp_path): "an explicit stamp is checked". """ f = _write_rant_lines(tmp_path, [ - {"timestamp": "2099-01-01T00:00:00+08:00", "project": "emrg", + {"timestamp": "2099-01-01T00:00:00+08:00", "agent": "emrg", "status": "in_progress", "progress": None, "completed": None, "message": "a row dated in the future"}, ]) @@ -413,7 +413,7 @@ def test_a_completion_stamp_is_written_only_with_the_completed_status(tmp_path): explicit stamp would pass the assertions above and break that one. """ stamp = "2026-08-18T10:00:00+08:00" - row = {"timestamp": stamp, "project": "emrg", "status": "in_progress", + row = {"timestamp": stamp, "agent": "emrg", "status": "in_progress", "progress": None, "completed": None, "message": "test rant"} # An allowed transition into a non-completed status, while handing over a completion stamp. @@ -451,14 +451,14 @@ def test_cleanup_rants_keeps_pending_plus_10_completed(tmp_path): ("2026-08-18T02:00:00+08:00", "in_progress"), ("2026-08-18T03:00:00+08:00", "pending"), ]): - entries.append({"timestamp": ts, "project": "emrg", "status": status, + entries.append({"timestamp": ts, "agent": "emrg", "status": status, "progress": None, "completed": None, "message": f"active {i}"}) # 12 completed rants for i in range(12): entries.append({ "timestamp": f"2026-08-18T{i + 4:02d}:00:00+08:00", - "project": "emrg", "status": "completed", "progress": None, + "agent": "emrg", "status": "completed", "progress": None, "completed": f"2026-08-18T{i + 4:02d}:30:00+08:00", "message": f"done {i}", }) @@ -544,7 +544,7 @@ def test_full_is_read_by_the_shared_reader_so_a_bad_spelling_is_refused(tmp_path # them apart. What only the full read has is the end of the body — and what only the scan # has is its own line saying it withheld something. _write_rant_lines(tmp_path, [ - {"timestamp": "2026-10-15T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-10-15T09:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "head-excerpt " + "a" * 120 + " tail-marker-" + "z" * 300}, ]) @@ -624,10 +624,10 @@ def test_the_list_action_returns_the_whole_message_when_the_caller_asks(tmp_path + " tail-marker-" + "z" * 300 ) _write_rant_lines(tmp_path, [ - {"timestamp": "2026-09-17T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-09-17T09:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": message}, - {"timestamp": "2026-09-17T09:01:00+08:00", "project": "emrg", + {"timestamp": "2026-09-17T09:01:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "short rant"}, ]) @@ -693,10 +693,10 @@ def test_an_unfiltered_list_is_the_scan_view_and_names_the_call_that_returns_the history = ("a completed rant's body, written when it was filed" + " history-tail-" + "h" * 400) _write_rant_lines(tmp_path, [ - {"timestamp": "2026-10-15T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-10-15T09:00:00+08:00", "agent": "emrg", "status": "in_progress", "progress": "Stage 1 landed (PR #1)", "completed": None, "message": work}, - {"timestamp": "2026-10-15T09:01:00+08:00", "project": "emrg", + {"timestamp": "2026-10-15T09:01:00+08:00", "agent": "emrg", "status": "completed", "progress": "done", "completed": "2026-10-15T10:00:00+08:00", "message": history}, ]) @@ -753,24 +753,24 @@ def test_project_alone_does_not_unlock_the_text(tmp_path, monkeypatch): The unlock is named precisely: the text comes when the caller filters by **status** or asks explicitly. That sentence is the wording of rant 2026-10-15T12:35:03 item (b) (「正文只在按 status 过滤或显式要求时给」), which writes out the host's decision of the same - day, 2026-10-15T12:34:24: 「就定 a + b(c 去掉)」. `project=` is the odd one out, and + day, 2026-10-15T12:34:24: 「就定 a + b(c 去掉)」. `agent=` is the odd one out, and deliberately so: it is the dedup filter `promote_prompt.md` uses, and on the queue measured - 2026-10-15 it selects **48 of 50** rows — `list(project="emrg")` cost 24,539 characters + 2026-10-15 it selects **48 of 50** rows — `list(agent="emrg")` cost 24,539 characters against an unfiltered 26,208, i.e. it narrowed the *subject*, not the cost. Printing bodies for it would reintroduce the default-read cost on the one filter a template really passes. """ monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) body = "a pending rant in the project the caller named" + " tail-" + "t" * 400 _write_rant_lines(tmp_path, [ - {"timestamp": "2026-10-15T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-10-15T09:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": body}, - {"timestamp": "2026-10-15T09:01:00+08:00", "project": "other", + {"timestamp": "2026-10-15T09:01:00+08:00", "agent": "other", "status": "pending", "progress": None, "completed": None, "message": "another project's rant"}, ]) tool = SubmitRantTool() out = __import__("asyncio").run( - tool.execute({"action": "list", "project": "emrg"})).content + tool.execute({"action": "list", "agent": "emrg"})).content assert "2026-10-15T09:00:00" in out, "the filter still selects the row" assert "another project" not in out, "…and excludes the other project's" assert "tail-" + "t" * 400 not in out, ( @@ -806,10 +806,10 @@ def test_the_list_header_is_bounded_and_the_full_progress_follows_it(tmp_path, m message = ("a short message, then enough filler that the marker below sits past the " "excerpt's " + "f" * 80 + " message-tail-" + "m" * 300) _write_rant_lines(tmp_path, [ - {"timestamp": "2026-09-17T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-09-17T09:00:00+08:00", "agent": "emrg", "status": "in_progress", "progress": progress, "completed": None, "message": message}, - {"timestamp": "2026-09-17T09:01:00+08:00", "project": "emrg", + {"timestamp": "2026-09-17T09:01:00+08:00", "agent": "emrg", "status": "pending", "progress": "PR #1 submitted", "completed": None, "message": "short rant"}, ]) @@ -883,10 +883,10 @@ def test_a_completed_body_is_withheld_and_one_call_returns_it(tmp_path, monkeypa progress = ("Stage 1 landed; Stage 2 is the remaining half." + " progress-tail-" + "p" * 300) _write_rant_lines(tmp_path, [ - {"timestamp": "2026-09-20T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-09-20T09:00:00+08:00", "agent": "emrg", "status": "completed", "progress": progress, "completed": "2026-09-20T18:00:00+08:00", "message": body}, - {"timestamp": "2026-09-21T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-09-21T09:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "the work actually in flight"}, ]) @@ -951,7 +951,7 @@ def test_tool_list_action(tmp_path, monkeypatch): tool = SubmitRantTool() f = tmp_path / "rants.jsonl" _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "some pending rant"}, ]) @@ -969,7 +969,7 @@ def test_tool_update_action(tmp_path, monkeypatch): monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) tool = SubmitRantTool() _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "test rant"}, ]) @@ -1000,7 +1000,7 @@ def test_tool_update_action(tmp_path, monkeypatch): assert entry["completed"] # pending → completed (no skipping) → error, no crash _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T09:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T09:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "fresh pending rant"}, ]) @@ -1015,7 +1015,7 @@ def test_tool_cleanup_action(tmp_path, monkeypatch): monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) tool = SubmitRantTool() _write_rant_lines(tmp_path, [ - {"timestamp": "2026-08-18T10:00:00+08:00", "project": "emrg", + {"timestamp": "2026-08-18T10:00:00+08:00", "agent": "emrg", "status": "pending", "progress": None, "completed": None, "message": "active rant"}, ]) @@ -1042,7 +1042,7 @@ def test_tool_definition_exposes_actions(): } # submit contract (project/message) still documented assert "message" in d.parameters["properties"] - assert "project" in d.parameters["properties"] + assert "agent" in d.parameters["properties"] assert "consent" in d.description.lower() or "confirm" in d.description.lower() @@ -1098,7 +1098,7 @@ def test_write_rants_refuses_a_named_pipe_too(tmp_path): os.mkfifo(fifo) row = { "timestamp": "2026-10-11T11:00:00+08:00", - "project": "emrg", + "agent": "emrg", "status": "pending", "progress": None, "completed": None, @@ -1154,13 +1154,13 @@ def test_a_named_pipe_projects_yml_yields_no_candidates(tmp_path, monkeypatch): tool = SubmitRantTool() os.mkfifo(tmp_path / "agents.yml") - assert within(DEADLINE_SECONDS, tool._registered_project_names) == [] + assert within(DEADLINE_SECONDS, tool._registered_agent_names) == [] (tmp_path / "agents.yml").unlink() (tmp_path / "agents.yml").write_text( "- name: emrg\n- name: aitokenpool\n", encoding="utf-8") - assert sorted(within(DEADLINE_SECONDS, tool._registered_project_names)) == [ + assert sorted(within(DEADLINE_SECONDS, tool._registered_agent_names)) == [ "aitokenpool", "emrg"] @@ -1192,11 +1192,11 @@ def test_projects_yml_absent_or_malformed_yields_no_candidates(tmp_path, monkeyp monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) tool = SubmitRantTool() - assert tool._registered_project_names() == [] # absent + assert tool._registered_agent_names() == [] # absent (tmp_path / "agents.yml").write_text("just a string\n", encoding="utf-8") - assert tool._registered_project_names() == [] # not a list + assert tool._registered_agent_names() == [] # not a list (tmp_path / "agents.yml").write_text(": :\n", encoding="utf-8") - assert tool._registered_project_names() == [] # unparseable + assert tool._registered_agent_names() == [] # unparseable (tmp_path / "agents.yml").write_text( "- name: emrg\n- notadict\n- name: ''\n", encoding="utf-8") - assert tool._registered_project_names() == ["emrg"] + assert tool._registered_agent_names() == ["emrg"] From 23543a1eef2cdd7bde200e9d56faa88eedc7f150 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 16:45:00 +0800 Subject: [PATCH 03/11] emrg: the registry answers to agent, and the old name leaves the tree projects_list/project_removed become agents_list/agent_removed, list_projects/ remove_project become list_agents/remove_agent, and the identifiers that carried the old entity name follow across the daemon, the scheduler, the tools, both clients and the tests. The projection family (modelLineProjections, streamProject, the Projection types) is a different word and is untouched, as is pyproject.toml. --- emrg/__main__.py | 24 +- emrg/client/app.py | 182 ++++----- emrg/client/widgets.py | 40 +- emrg/config.py | 2 +- emrg/gui/daemon_client.js | 12 +- emrg/gui/gui-state.js | 2 +- emrg/gui/main.js | 64 +-- emrg/gui/preload.js | 10 +- emrg/gui/renderer/css/components.css | 8 +- emrg/gui/renderer/package-lock.json | 6 +- .../src/components/DialogHost.test.tsx | 16 +- .../renderer/src/components/DialogHost.tsx | 96 ++--- .../src/components/MemoryDialog.test.tsx | 2 +- .../renderer/src/components/MemoryDialog.tsx | 6 +- .../src/components/NewSessionDialog.test.tsx | 32 +- .../src/components/NewSessionDialog.tsx | 40 +- .../src/components/OpenSessionDialog.test.tsx | 44 +- .../src/components/OpenSessionDialog.tsx | 60 +-- .../src/components/RantDialog.test.tsx | 20 +- .../renderer/src/components/RantDialog.tsx | 20 +- .../renderer/src/components/Shell.test.tsx | 66 +-- emrg/gui/renderer/src/components/Shell.tsx | 102 ++--- .../renderer/src/components/Sidebar.test.tsx | 14 +- emrg/gui/renderer/src/components/Sidebar.tsx | 8 +- .../src/components/SkillsDialog.test.tsx | 4 +- .../src/components/TaskFormDialog.test.tsx | 26 +- .../src/components/TaskFormDialog.tsx | 26 +- .../src/components/TranscriptView.tsx | 4 +- .../src/components/WorkspaceView.test.tsx | 66 +-- .../renderer/src/components/WorkspaceView.tsx | 120 +++--- emrg/gui/renderer/src/lib/dialogLists.test.ts | 4 +- emrg/gui/renderer/src/lib/i18n-dicts.ts | 116 +++--- emrg/gui/renderer/src/lib/openSession.test.ts | 30 +- emrg/gui/renderer/src/lib/openSession.ts | 20 +- emrg/gui/renderer/src/lib/sidebar.test.ts | 6 +- emrg/gui/renderer/src/lib/sidebar.ts | 8 +- .../renderer/src/lib/workspaceView.test.ts | 20 +- emrg/gui/renderer/src/lib/workspaceView.ts | 18 +- emrg/gui/test/boot-contract.test.js | 4 +- emrg/gui/test/daemon_client.test.js | 10 +- emrg/gui/test/integration.test.js | 10 +- emrg/gui/test/preload-api.test.js | 10 +- emrg/gui/vendor/highlight.custom.js | 2 +- emrg/gui/vendor/monaco/vs/ecl-30fUercY.js | 2 +- emrg/gui/vendor/monaco/vs/editor-KLE6jdfb.js | 2 +- .../monaco/vs/editorWorkerHost-fVE1cjcC.js | 2 +- emrg/memory.py | 90 ++--- emrg/sandbox/contract.py | 2 +- emrg/sandbox/fence.py | 2 +- emrg/sandbox/providers/win32.py | 2 +- emrg/server/daemon.py | 170 ++++---- emrg/server/git_utils.py | 4 +- emrg/server/prompts/system.j2 | 22 +- emrg/server/prompts/upgrade_prompt.j2 | 2 +- emrg/server/rants.py | 2 +- emrg/server/redaction.py | 2 +- emrg/server/scheduler.py | 116 +++--- emrg/session.py | 10 +- emrg/sessions_index.py | 26 +- emrg/skills/loader.py | 44 +- emrg/tool_path.py | 4 +- emrg/tools/command_scan.py | 4 +- emrg/tools/glob_tool.py | 6 +- emrg/tools/grep_tool.py | 4 +- emrg/tools/subagent_tool.py | 4 +- scripts/bump-version.py | 2 +- scripts/check-doc-count.py | 4 +- scripts/check-extension-load.py | 2 +- scripts/check-gui-package-refs.py | 8 +- scripts/check-issue-links.py | 8 +- scripts/check-memory-index.py | 18 +- scripts/check-memory-rotation.py | 244 +++++------ scripts/check-merge-freshness.py | 8 +- scripts/check-merge-plan-suite.py | 18 +- scripts/review-queue.py | 90 ++--- tests/conftest.py | 4 +- tests/task_handler_factory.py | 2 +- ...est_a_tree_reading_guard_names_its_tree.py | 14 +- tests/test_agent.py | 12 +- tests/test_agent_md_prompt_cap.py | 16 +- tests/test_agent_registry.py | 2 +- tests/test_agents_registry_migration.py | 10 +- tests/test_app_replay_rows.py | 10 +- tests/test_app_resume_cwd.py | 6 +- tests/test_app_selector_state.py | 14 +- tests/test_app_task_session.py | 24 +- tests/test_app_widgets.py | 94 ++--- tests/test_bash_v2_win32.py | 6 +- tests/test_bump_version.py | 6 +- tests/test_check_doc_count.py | 6 +- tests/test_check_extension_load.py | 2 +- tests/test_check_gui_package_refs.py | 14 +- tests/test_check_issue_links.py | 18 +- tests/test_check_memory_index.py | 2 +- tests/test_check_memory_rotation.py | 210 +++++----- tests/test_check_merge_freshness.py | 4 +- tests/test_check_merge_plan_suite.py | 12 +- tests/test_check_merge_sequence.py | 2 +- tests/test_check_node_test_count.py | 4 +- tests/test_check_notary_credentials.py | 2 +- tests/test_check_unbound_reads.py | 2 +- tests/test_check_undefined_names.py | 2 +- tests/test_ci_nonlocal.py | 2 +- tests/test_cli_failure_reporting.py | 4 +- tests/test_competition_prompt.py | 26 +- tests/test_config_file_mode_census.py | 2 +- tests/test_config_reload.py | 2 +- tests/test_daemon.py | 300 +++++++------- tests/test_daemon_lifecycle_is_classified.py | 6 +- tests/test_daemon_tool_path.py | 2 +- tests/test_documented_scripts_exist.py | 2 +- tests/test_evolution_prompt_index_rule.py | 8 +- tests/test_evolution_prompt_issue_rule.py | 2 +- .../test_evolution_prompt_merge_condition.py | 10 +- tests/test_evolution_prompt_prompt_drift.py | 34 +- tests/test_evolution_prompt_red_lines.py | 8 +- tests/test_evolution_prompt_vote_body.py | 8 +- tests/test_git_utils.py | 2 +- tests/test_grep_tool.py | 4 +- tests/test_guard_scope.py | 2 +- tests/test_gui_config_mode.py | 6 +- tests/test_hermeticity_guard.py | 8 +- tests/test_language_policy_reach.py | 4 +- tests/test_local_exclude.py | 14 +- tests/test_main.py | 12 +- tests/test_memory.py | 200 ++++----- tests/test_memory_block_leaves_the_prefix.py | 6 +- tests/test_memory_index_thresholds.py | 18 +- tests/test_memory_reflection.py | 18 +- tests/test_memory_row_bound_reach.py | 4 +- tests/test_no_background_process_reach.py | 8 +- tests/test_plain_language_rule_reach.py | 8 +- tests/test_posix_arms_are_gated.py | 2 +- tests/test_project_context_paths.py | 14 +- tests/test_prompt_templates.py | 112 +++--- tests/test_protocol.py | 4 +- tests/test_rant_citations.py | 2 +- tests/test_rants_single_writer.py | 2 +- tests/test_redaction.py | 2 +- tests/test_review_queue.py | 126 +++--- tests/test_schedule_window.py | 12 +- tests/test_scheduler.py | 380 +++++++++--------- ..._and_llm_logs_apply_the_credential_rule.py | 2 +- tests/test_scratch_roots_are_gitignored.py | 6 +- tests/test_session.py | 12 +- tests/test_session_delete_path.py | 30 +- tests/test_sessions_index.py | 22 +- tests/test_shell_tool_mount.py | 2 +- tests/test_sinks_agree_on_the_frame_logger.py | 2 +- tests/test_skills_loader.py | 24 +- tests/test_skills_read_live.py | 90 ++--- tests/test_skills_registry.py | 4 +- tests/test_subagent_tool.py | 4 +- tests/test_submit_rant_tool.py | 32 +- tests/test_system_prompt_structure.py | 8 +- tests/test_tool_call_batch_label.py | 2 +- tests/test_turn_context_is_persisted.py | 2 +- tests/test_turn_terminal_frame.py | 2 +- tests/test_upgrade_chain_red_line.py | 14 +- tests/test_uv_lock_index_is_pinned.py | 10 +- tests/test_win32_spawn_hides_the_console.py | 2 +- tests/test_ws_e2e.py | 72 ++-- 162 files changed, 2266 insertions(+), 2266 deletions(-) diff --git a/emrg/__main__.py b/emrg/__main__.py index 3882670a5..90fd5b23b 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -49,7 +49,7 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument( "--init-auto-evolve", action="store_true", - help="Enable auto-evolution for the current project on connect.", + help="Enable auto-evolution for the current agent on connect.", ) sub = parser.add_subparsers(dest="command", metavar="[command]") @@ -99,7 +99,7 @@ def _build_parser() -> argparse.ArgumentParser: description="Update emrg by pulling latest source and reinstalling.", ) - # emrg rant [--project ] + # emrg rant [--agent ] rant_parser = sub.add_parser( "rant", help="Send feedback/complaint to EMRG for evolution analysis", @@ -107,8 +107,8 @@ def _build_parser() -> argparse.ArgumentParser: "evolution system will use to discover improvement opportunities.", ) rant_parser.add_argument( - "-p", "--project", - help="Target project (from agents.yml). Omit for emrg itself.", + "-p", "--agent", + help="Target agent (from agents.yml). Omit for emrg itself.", default=None, ) rant_parser.add_argument( @@ -160,7 +160,7 @@ def main() -> None: else: _run_daemon() elif parsed.command == "rant": - _send_rant(" ".join(parsed.message), project=parsed.project) + _send_rant(" ".join(parsed.message), agent=parsed.agent) elif parsed.command == "stop": # Windows installer pre-stop depends on the non-zero exit code when # residual processes remain (emrg/_stop_all.py owns the full logic). @@ -417,13 +417,13 @@ def _run_daemon() -> None: # ── Rant ────────────────────────────────────────────────────── -def _send_rant(message: str, project: str | None = None) -> None: +def _send_rant(message: str, agent: str | None = None) -> None: """Send a rant/feedback message to the daemon for evolution analysis.""" - # Parse @project from message if -p/--project not explicitly set - # (matching TUI /rant @project behavior — app.py:1365-1369) - if project is None and message.startswith("@"): + # Parse @agent from message if -p/--agent not explicitly set + # (matching TUI /rant @agent behavior — app.py:1365-1369) + if agent is None and message.startswith("@"): parts = message.split(None, 1) - project = parts[0][1:] # strip @ + agent = parts[0][1:] # strip @ message = parts[1] if len(parts) > 1 else "" async def _do() -> None: @@ -444,8 +444,8 @@ async def _do() -> None: "message": message, "timestamp": datetime.now().isoformat(), } - if project: - payload["project"] = project + if agent: + payload["agent"] = agent await ws.send(json.dumps(payload, ensure_ascii=False)) diff --git a/emrg/client/app.py b/emrg/client/app.py index 9044f8567..3dd88e00f 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -17,7 +17,7 @@ from emrg.client.python_tui.pending_rows import ParkedUserRows from emrg.client.python_tui.widgets.markdown import StreamingMarkdown from emrg.client.widgets import ( - InputWidget, RewindSelector, SessionSelector, ProjectSelector, + InputWidget, RewindSelector, SessionSelector, AgentSelector, TaskSelector, ModelSelector, SandboxSelector, CommandDropdown, ChatHistory, SelectorState, _COMMAND_HELP, ToolSelector, @@ -171,7 +171,7 @@ def _auto_title_from_prompt(text: str, max_len: int = 30) -> str | None: def _task_session_target(task: dict | None) -> tuple[str, str] | None: """The ``(cwd, session_id)`` that opening a task's session needs. - ``list_tasks`` rows already carry both (`handler.status()` → `project_path`, + ``list_tasks`` rows already carry both (`handler.status()` → `agent_path`, `session_id`), so /task-session needs no server change (rant 2026-09-17T18:36:08). Returns ``None`` when they cannot be used — a task that has never run, or a row without them — and the caller then reports that @@ -181,11 +181,11 @@ def _task_session_target(task: dict | None) -> tuple[str, str] | None: Module-level and pure so it is unit-testable without a terminal: the branch it feeds is otherwise only reachable by driving the TUI. Missing, empty and non-string fields all count as unusable — `os.getcwd`-style falsiness is not - an answer to "where is this project". + an answer to "where is this agent". """ if not isinstance(task, dict): return None - cwd = task.get("project_path") + cwd = task.get("agent_path") sid = task.get("session_id") if not isinstance(cwd, str) or not cwd.strip(): return None @@ -202,7 +202,7 @@ def _task_open_switch(pending: tuple[str, str] | None, resumed_sid: str) -> str answer is about a *different* session than the one the open asked for. The second is not hypothetical — an open whose verdict never arrived (the connection dropped) leaves its target behind, and a later `/resume ` - succeeding must not then drag the host into a project they never picked, + succeeding must not then drag the host into a agent they never picked, which is the defect a cwd is the wrong place to discover. """ if pending is None or pending[1] != resumed_sid: @@ -217,7 +217,7 @@ def _resume_target_from_resolution( Rant 2026-09-29T15:52:49 (requirement 6, the client half). The client used to send its **own** cwd with every ``resume_session``, which is only ever right - by accident: a session belonging to another project — a scheduled task's + by accident: a session belonging to another agent — a scheduled task's cycle above all — lives under a different ``.emrg/sessions/``, and the daemon's broadcast filter is keyed by ``(session, cwd)``, so the wrong cwd both fails to open it and, where it opens, leaves the live frames addressed @@ -227,12 +227,12 @@ def _resume_target_from_resolution( names a different session than the one asked for. Same discipline as ``_task_open_switch`` at the other end of the same round trip, and for the same reason — a verdict that arrives after its request was abandoned must not - move the host into a project they never picked. + move the host into a agent they never picked. An ``unknown`` answer is **not** the same reading as an answer that carries a - cwd. The daemon cannot vouch for the session's project when the global index + cwd. The daemon cannot vouch for the session's agent when the global index does not know it, so the client keeps the cwd it has (correct for a session in - the current project) rather than treating a guess as a fact. + the current agent) rather than treating a guess as a fact. """ if not pending_sid or result.get("session_id") != pending_sid: return None @@ -282,7 +282,7 @@ def _cards_from_tool_calls(tool_calls) -> list[ToolCard]: status="pending", tool_call_id=str(call.get("id") or ""), arguments=args, - # The daemon projects `call_label` into every history entry it returns, + # The daemon agents `call_label` into every history entry it returns, # so a resumed session shows the same label the live one did — including # a session recorded before the field existed (rant 2026-10-20T12:22:21). call_label=str(call.get("call_label") or ""), @@ -384,7 +384,7 @@ def _replay_rows(messages) -> list[tuple[str, object, str]]: contract: `kind: "message"` for the user, assistant and system roles, `kind: "tool_result"` for tool output, and `kind: "summary"` for a compaction boundary. A record outside that set is skipped rather than rendered as a blank - row — a role the file on disk may hold and the records mode does not project is + row — a role the file on disk may hold and the records mode does not agent is invented nowhere here, because inventing it would put the TUI and the GUI back on two different content paths, which is the defect that requirement removes. @@ -1124,7 +1124,7 @@ async def interactive(init_auto_evolve: bool = False, console=None): # Session setup cwd = os.getcwd() session_id = generate_session_id(Path(cwd)) - project_name = Path(cwd).name + agent_name = Path(cwd).name # Send init_auto_evolve if requested (before ping, so daemon # processes it before any user interaction starts) @@ -1210,7 +1210,7 @@ def _narrate_the_stop() -> None: status.elapsed = "" # Rant 2026-09-02T10:31:11:与 done 路径(:617)一致的标题复位——中断 # 漏清 term.set_title 会让标题残留 [m:ss] 计时停住。 - term.set_title(f"{session_title or session_id} @ {project_name}") + term.set_title(f"{session_title or session_id} @ {agent_name}") chat.add("system", CANCELLED_LINE) _last_center = server_id or "emrg" status.update(center=_last_center) @@ -1272,7 +1272,7 @@ async def _run_elapsed_timer() -> None: mins, secs = divmod(elapsed, 60) timer = f"[{mins}:{secs:02d}]" status.elapsed = timer - term.set_title(f"{timer} {session_title or session_id} @ {project_name}") + term.set_title(f"{timer} {session_title or session_id} @ {agent_name}") term.render() except Exception as e: logger.error("elapsed timer error: %s", e) @@ -1297,13 +1297,13 @@ def _ensure_elapsed_timer() -> None: # Selector state — each selector has an active flag, widget ref, and pending flag. session_sel = SelectorState() delete_sel = SelectorState() - project_sel = SelectorState() + agent_sel = SelectorState() model_sel = SelectorState() sandbox_sel = SelectorState() rewind_sel = SelectorState() task_sel = SelectorState() tool_sel = SelectorState() # Tab: the tool-detail selector (rant 2026-09-30T09:17:54) - _rant_project: str | None = None # Set after project selection, used on next Enter + _rant_agent: str | None = None # Set after agent selection, used on next Enter _skills_confirm: tuple | None = None # (skill_name, install_cmd) — next Enter answers the prompt # (request_id, question, deadline) while the daemon is waiting on this # client's answer to an escalation request — the next line typed is the @@ -1362,8 +1362,8 @@ async def read_server(): nonlocal _last_center, _elapsed_task, conn nonlocal _request_start, turn_running # /task-session: the daemon's verdict on a task's session decides whether - # the client moves into that task's project at all (rant 2026-09-17T18:36:08). - nonlocal cwd, project_name, _task_list_intent, _task_open_pending + # the client moves into that task's agent at all (rant 2026-09-17T18:36:08). + nonlocal cwd, agent_name, _task_list_intent, _task_open_pending nonlocal _resume_pending_sid nonlocal _replay_pending nonlocal _rewind_fill, _rewind_replay @@ -1447,7 +1447,7 @@ async def _reconnect(): ver = getattr(emrg, "__version__", "dev") chat.add("system", f"EMRG {ver} | {server_id}\nType /help for shortcuts, or just start chatting.") status.update(left=_status_left(session_title, session_id, current_model, current_vision, current_sandbox), center=server_id) - term.set_title(f"{session_title or session_id} @ {project_name}") + term.set_title(f"{session_title or session_id} @ {agent_name}") term.render(); continue # P1 queue-injection client side (daemon #655): messages sent @@ -1744,7 +1744,7 @@ async def _reconnect(): chat.add("system", f"⚠ {resp.content} Try '继续' to resume.") _last_center = server_id or "emrg" status.update(center=_last_center) - term.set_title(f"{session_title or session_id} @ {project_name}") + term.set_title(f"{session_title or session_id} @ {agent_name}") # rant 21:52:18: daemon's done frame reports the authoritative # current-context message count (system + history + tool results); # fall back to the local +1 approximation when absent. @@ -1816,7 +1816,7 @@ async def _reconnect(): chat.dirty = True chat.add("system", f"Created new session {new_sid} — continue chatting.") status.update(left=_status_left("", new_sid, current_model, current_vision, current_sandbox), center=server_id or "emrg") - term.set_title(f"{new_sid} @ {project_name}") + term.set_title(f"{new_sid} @ {agent_name}") msg_count = 0 _update_left_extra() status.update(center=server_id or "emrg") @@ -2116,7 +2116,7 @@ async def _reconnect(): f"({count} messages, " f"created {_created}, updated {_updated})") status.update(left=_status_left(session_title, session_id, current_model, current_vision, current_sandbox), center=server_id or "emrg") - term.set_title(f"{session_title or pending_sid} @ {project_name}") + term.set_title(f"{session_title or pending_sid} @ {agent_name}") _update_left_extra() term.render() continue @@ -2138,29 +2138,29 @@ async def _reconnect(): term.render() continue - # Projects list - if data.get("type") == "projects_list": - nonlocal project_sel - projects = data.get("projects", []) + # Agents list + if data.get("type") == "agents_list": + nonlocal agent_sel + agents = data.get("agents", []) err = data.get("error", "") if err: chat.add("system", f"Error: {err}") - project_sel.pending = False - elif project_sel.pending and projects: - project_sel.pending = False - project_sel.widget = ProjectSelector(projects) - project_sel.active = True - chat.add(project_sel.widget) - status.update(center="select project: ↑↓ Enter Esc (j/k vim)") + agent_sel.pending = False + elif agent_sel.pending and agents: + agent_sel.pending = False + agent_sel.widget = AgentSelector(agents) + agent_sel.active = True + chat.add(agent_sel.widget) + status.update(center="select agent: ↑↓ Enter Esc (j/k vim)") else: - project_sel.pending = False - if projects: - project_sel.widget = ProjectSelector(projects) - project_sel.active = True - chat.add(project_sel.widget) - status.update(center="select project: ↑↓ Enter Esc (j/k vim)") + agent_sel.pending = False + if agents: + agent_sel.widget = AgentSelector(agents) + agent_sel.active = True + chat.add(agent_sel.widget) + status.update(center="select agent: ↑↓ Enter Esc (j/k vim)") else: - chat.add("system", "No projects configured. Use emrg in a git repo to auto-register.") + chat.add("system", "No agents configured. Use emrg in a git repo to auto-register.") term.render() continue @@ -2429,7 +2429,7 @@ async def _reconnect(): # Where a session lives, asked before resuming it (rant # 2026-09-29T15:52:49, requirement 6). The client used to send its # own cwd with every resume; the daemon answers with the session's - # real project instead, and the pair is then handed to the same + # real agent instead, and the pair is then handed to the same # `_task_open_pending` machinery that moves the client into it. if data.get("type") == "session_cwd_result": target = _resume_target_from_resolution( @@ -2492,13 +2492,13 @@ async def _reconnect(): current_roots = list(meta.get("sandbox_roots") or []) # /task-session: the session exists, so the client now moves - # into that task's project — before the history replay below, + # into that task's agent — before the history replay below, # which reads it from `cwd`. switch_to = _task_open_switch(_task_open_pending, new_sid) _task_open_pending = None if switch_to is not None: cwd = switch_to - project_name = Path(cwd).name + agent_name = Path(cwd).name # Clear and replay: the material comes from the daemon, not # from disk (rant 2026-09-29T15:52:49, requirement 4 — two @@ -2547,7 +2547,7 @@ async def _reconnect(): session_title = new_title chat.add("system", f"Session renamed to: {new_title}") status.update(left=_status_left(session_title, session_id, current_model, current_vision, current_sandbox), center=server_id or "emrg") - term.set_title(f"{session_title} @ {project_name}") + term.set_title(f"{session_title} @ {agent_name}") term.render() continue @@ -2557,7 +2557,7 @@ async def _reconnect(): if err: chat.add("system", f"Error: {err}") else: - scope = data.get("scope", "project") + scope = data.get("scope", "agent") directory = data.get("directory", "") memories = data.get("memories", []) lines = [f"Memories ({scope}):", f" Directory: {directory}", ""] @@ -2574,7 +2574,7 @@ async def _reconnect(): lines.append("") lines.append("Type /memory to read a specific memory.") lines.append("Type /memory session for session memories.") - lines.append("Type /memory project for project memories.") + lines.append("Type /memory agent for agent memories.") else: lines.append(" (no memories yet)") chat.add("system", "\n".join(lines)) @@ -2713,8 +2713,8 @@ def _handle_selector_nav(data: bytes, widget) -> bool: async def handle_key(data: bytes) -> bool: nonlocal inp, status, history, paste_mode, stream_buffer, conn, chat, busy, need_new_assistant, session_id, session_title, msg_count, cwd nonlocal turn_ended_cancelled, cancel_receipt_held - nonlocal current_model, project_name, current_sandbox - nonlocal session_sel, delete_sel, project_sel, model_sel, rewind_sel, task_sel + nonlocal current_model, agent_name, current_sandbox + nonlocal session_sel, delete_sel, agent_sel, model_sel, rewind_sel, task_sel nonlocal _rewind_fill nonlocal sandbox_sel, tool_sel nonlocal _task_list_intent, _task_open_pending @@ -2874,35 +2874,35 @@ async def handle_key(data: bytes) -> bool: return True return True - # ── Project selector mode ────────────────────────── - if project_sel.active and project_sel.widget: + # ── Agent selector mode ────────────────────────── + if agent_sel.active and agent_sel.widget: if data == b"\x1b": # Esc — cancel selection - project_sel.active = False - chat.add("system", "Project selection cancelled.") - chat.remove(project_sel.widget) - project_sel.widget = None + agent_sel.active = False + chat.add("system", "Agent selection cancelled.") + chat.remove(agent_sel.widget) + agent_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() return True if data == b"\r" or data == b"\n": # Enter — confirm - pname = project_sel.widget.selected_project_name - project_sel.active = False - chat.remove(project_sel.widget) - project_sel.widget = None + pname = agent_sel.widget.selected_agent_name + agent_sel.active = False + chat.remove(agent_sel.widget) + agent_sel.widget = None if pname: - nonlocal _rant_project - _rant_project = pname - chat.add("system", f"Rant to project '@{pname}' — type your message and press Enter:") + nonlocal _rant_agent + _rant_agent = pname + chat.add("system", f"Rant to agent '@{pname}' — type your message and press Enter:") status.update(center=f"rant to @{pname}") else: - chat.add("system", "No project selected.") + chat.add("system", "No agent selected.") status.update(center=server_id or "emrg") chat.dirty = True; term.render() return True - if _handle_selector_nav(data, project_sel.widget): + if _handle_selector_nav(data, agent_sel.widget): chat.dirty = True; term.render() return True - # Ignore other keys when in project selector mode + # Ignore other keys when in agent selector mode return True # ── Model selector mode ────────────────────────── @@ -3026,7 +3026,7 @@ async def handle_key(data: bytes) -> bool: if task_intent == "session": # /task-session (rant 2026-09-17T18:36:08): open the task's # session instead of triggering it. The cwd the daemon is - # asked with is the TASK's project — the client's own cwd + # asked with is the TASK's agent — the client's own cwd # only moves once the daemon confirms the session exists, so # a never-run task leaves the host where they were. target = _task_session_target(task_row) @@ -3242,14 +3242,14 @@ async def handle_key(data: bytes) -> bool: if text: if text.lower() in ("quit", "exit"): return False - # If a rant project was selected, use this message as the rant + # If a rant agent was selected, use this message as the rant # (rant 2026-08-17T11:51:59: routes through the agent for # polish/confirm, then the submit_rant tool records it) - if _rant_project: + if _rant_agent: hint = ( f"[Host wants to submit this rant — polish it, ask for " f"confirmation if needed, then call submit_rant " - f"(project: {_rant_project})]\n{text}" + f"(agent: {_rant_agent})]\n{text}" ) # This path echoes its own user row too, so it parks it the # same way the ordinary submit does — the id is minted here, @@ -3259,7 +3259,7 @@ async def handle_key(data: bytes) -> bool: # submit's). rid = new_task_id() parked_user_rows.park( - rid, chat.add("user", f"/rant @{_rant_project} {text}")) + rid, chat.add("user", f"/rant @{_rant_agent} {text}")) chat.add("assistant", "") msg_count += 1; _update_left_extra() _last_center = "thinking..." @@ -3269,14 +3269,14 @@ async def handle_key(data: bytes) -> bool: # path does before ITS send: `was_busy` is this function's # own local (the assignment further down is what binds it), # so a read that does not follow one raises - # UnboundLocalError — the /rant-with-project path crashed + # UnboundLocalError — the /rant-with-agent path crashed # on every Enter (issue #1759). was_busy = busy rid = await conn.send_task(session_id=session_id, cwd=cwd, prompt=hint, id=rid) if was_busy: _queued_sends.append({"id": rid, "prompt": hint, "images": None}) - _rant_project = None + _rant_agent = None status.update(center=server_id or "emrg") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True @@ -3336,15 +3336,15 @@ async def handle_key(data: bytes) -> bool: if text.lower().startswith("/memory"): parts = text.split(None, 1) sub = parts[1].strip() if len(parts) > 1 else "" - scope = "project" + scope = "agent" mem_id = "" if sub: # /memory session → list session memories # /memory → read specific memory if sub.lower() == "session": scope = "session" - elif sub.lower() == "project": - scope = "project" + elif sub.lower() == "agent": + scope = "agent" else: mem_id = sub # Could be a read request — send as read @@ -3433,7 +3433,7 @@ async def handle_key(data: bytes) -> bool: await conn.send_command("skills_update") status.update(center="checking skill updates…") else: - # The session's directory, not this process's: the project + # The session's directory, not this process's: the agent # skills a session has are the ones under its own root. skills = load_skills(Path(cwd) if cwd else None) if skills: @@ -3541,19 +3541,19 @@ def _is_image_token(s, i): Commands /help Show this help - /skills List loaded skills (user + project) + /skills List loaded skills (user + agent) /version Show EMRG version and instance info /image Insert clipboard image into input field /compact Compress conversation history /clear Clear current session and start fresh - /memory [session|project|] Browse memories + /memory [session|agent|] Browse memories /sessions Interactive session picker (↑↓/j/k to select) /resume [id] Switch to session (no args = interactive picker, ↑↓/j/k) /rename [title] Rename current session /delete [id] Delete session (no args = interactive picker, ↑↓/j/k) /rant Send feedback to evolution system - /rant @ Rant to a specific project - /rant Interactive project picker, then type message + /rant @ Rant to a specific agent + /rant Interactive agent picker, then type message /model [name] Switch LLM model (no args = interactive picker) /trigger List scheduled tasks (type name to trigger) /trigger Manually trigger a scheduled task now @@ -3594,29 +3594,29 @@ def _is_image_token(s, i): if text.lower().startswith("/rant"): parts = text.split(None, 2) message = parts[1].strip() if len(parts) > 1 else "" - project = None + agent = None if message.startswith("@"): - # /rant @ — project-targeted rant + # /rant @ — agent-targeted rant sub = text.split(None, 2) - project = sub[1][1:] # strip @ + agent = sub[1][1:] # strip @ message = sub[2].strip() if len(sub) > 2 else "" if not message: - if project: - chat.add("system", "Usage: /rant @ ") + if agent: + chat.add("system", "Usage: /rant @ ") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - # /rant without args → interactive project selector - project_sel.pending = True - await conn.send_command("list_projects") + # /rant without args → interactive agent selector + agent_sel.pending = True + await conn.send_command("list_agents") - status.update(center="loading projects...") + status.update(center="loading agents...") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - target = f" (@{project})" if project else "" + target = f" (@{agent})" if agent else "" hint = ( f"[Host wants to submit this rant — polish it, ask for " f"confirmation if needed, then call submit_rant " - f"(project: {project if project else 'emrg'})]\n{message}" + f"(agent: {agent if agent else 'emrg'})]\n{message}" ) # Parked before the send, under an id minted here — the other # `/rant` shape does the same, so an echoed row on either path @@ -3756,7 +3756,7 @@ def _is_image_token(s, i): session_sel.widget = None session_sel.pending = False # Ask where the session lives instead of assuming it is - # here: the id may name another project's session — a + # here: the id may name another agent's session — a # scheduled task's, above all — and `cwd` is this # client's, not that session's (rant 2026-09-29T15:52:49, # requirement 6). The answer drives the resume. diff --git a/emrg/client/widgets.py b/emrg/client/widgets.py index 7361e9f73..4eebb000b 100644 --- a/emrg/client/widgets.py +++ b/emrg/client/widgets.py @@ -1,6 +1,6 @@ """EMRG client — TUI widget classes extracted from app.py. -Contains InputWidget, selectors (Session/Project/Model/Rewind/Command), +Contains InputWidget, selectors (Session/Agent/Model/Rewind/Command), ChatHistory, _COMMAND_HELP, and SelectorState. """ @@ -657,15 +657,15 @@ def _format_tool_input(arguments: dict) -> list[str]: return text.split("\n") -class ProjectSelector(Widget): - """Interactive project picker — arrow-key navigation with highlight. +class AgentSelector(Widget): + """Interactive agent picker — arrow-key navigation with highlight. - Renders a list of projects from agents.yml with the selected one in - reverse video. Used by /rant when invoked without @project. + Renders a list of agents from agents.yml with the selected one in + reverse video. Used by /rant when invoked without @agent. """ - def __init__(self, projects: list[dict] | None = None): - self.projects: list[dict] = projects or [] + def __init__(self, agents: list[dict] | None = None): + self.agents: list[dict] = agents or [] self.selected_index: int = 0 self._dirty: bool = True @@ -683,25 +683,25 @@ def move_up(self) -> None: self._dirty = True def move_down(self) -> None: - if self.selected_index < len(self.projects) - 1: + if self.selected_index < len(self.agents) - 1: self.selected_index += 1 self._dirty = True @property - def selected_project_name(self) -> str | None: - if 0 <= self.selected_index < len(self.projects): - return self.projects[self.selected_index].get("name", "") + def selected_agent_name(self) -> str | None: + if 0 <= self.selected_index < len(self.agents): + return self.agents[self.selected_index].get("name", "") return None def render(self, ctx): lines: list[Line] = [] pstyle = Style.parse("bold cyan") avail = getattr(ctx, "height", 0) or 0 - n = len(self.projects) + n = len(self.agents) chrome, start, end, markers = window_plan(avail, n, self.selected_index) if chrome: lines.append(Line( - spans=[Span("○ ", style="dim"), Span("Select a project (↑↓/j/k to move, Enter to confirm, Esc to cancel):", style="bold")], + spans=[Span("○ ", style="dim"), Span("Select a agent (↑↓/j/k to move, Enter to confirm, Esc to cancel):", style="bold")], style=ctx.style, )) if markers and start > 0: @@ -710,7 +710,7 @@ def render(self, ctx): style=ctx.style, )) for i in range(start, end): - p = self.projects[i] + p = self.agents[i] name = p.get("name", "?") repo = p.get("repo", "") label = f" {name}" @@ -878,7 +878,7 @@ def selected_task_name(self) -> str | None: @property def selected_task(self) -> dict | None: - """The whole selected row — /task-session needs `project_path` and + """The whole selected row — /task-session needs `agent_path` and `session_id`, which `selected_task_name` does not carry.""" if 0 <= self.selected_index < len(self.tasks): return self.tasks[self.selected_index] @@ -1030,18 +1030,18 @@ def render(self, ctx): "/sessions": "Browse and switch between saved sessions (↑↓/j/k to navigate)", "/rewind": "Rewind session — pick a user message to truncate history to", "/compact": "Compress conversation history to save context", - "/memory": "Browse and search memories [session|project|]", + "/memory": "Browse and search memories [session|agent|]", "/rename": "Rename current session [title]", "/delete": "Delete a session [/delete | /delete ]", "/clear": "Clear current session history and start fresh", - "/rant": "Send feedback to the evolution system [/rant | /rant @ ]", + "/rant": "Send feedback to the evolution system [/rant | /rant @ ]", "/model": "Switch LLM model [/model | /model ]", "/sandbox": "Set this session's sandbox tier, or its extra writable roots " "[/sandbox | /sandbox | /sandbox add|remove|list ] " "[/sandbox add|remove --registry names a registry key]", "/trigger": "List or manually trigger scheduled tasks [/trigger | /trigger ]", "/task-session": "Open a scheduled task's session (no args = interactive picker)", - "/skills": "List loaded skills (user + project)", + "/skills": "List loaded skills (user + agent)", "/version": "Show EMRG version and instance info", "/image": "Insert clipboard image into input field", "/help": "Show keyboard shortcuts and commands", @@ -1259,7 +1259,7 @@ def render(self, ctx): class SelectorState: - """Unified state for interactive selectors (session, project, model). + """Unified state for interactive selectors (session, agent, model). Consolidates 9 separate variables into 3 typed instances to eliminate nonlocal declaration errors (rant #31). @@ -1268,5 +1268,5 @@ class SelectorState: def __init__(self) -> None: self.active: bool = False - self.widget: 'SessionSelector | ProjectSelector | ModelSelector | None' = None + self.widget: 'SessionSelector | AgentSelector | ModelSelector | None' = None self.pending: bool = False diff --git a/emrg/config.py b/emrg/config.py index 1064cbfa4..c4a39168c 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -224,7 +224,7 @@ def logs_dir() -> Path: #: The register's name before an Agent became the record of the directory a #: session runs in (#2521). Spelled here and nowhere else in the package: the #: move below is what retires it. -_LEGACY_AGENTS_NAME = "projects.yml" +_LEGACY_AGENTS_NAME = "agents.yml" def agents_file(base: Path | None = None) -> Path: diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 789f8f48c..77cd930c4 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -123,7 +123,7 @@ function isSessionId(id) { const RESPONSE_TYPES = { list_sessions: "sessions_list", list_models: "models_list", - list_projects: "projects_list", + list_agents: "agents_list", list_history: "history_list", // The prompts the host typed in one session (issue #2349, part 2/2): the daemon // answers a `list_input_history` command with an `input_history` frame, so the @@ -164,8 +164,8 @@ const RESPONSE_TYPES = { * * 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 + * carries the agent the global session index holds, and `unknown` carries + * `null` — the daemon cannot vouch for that session's agent, 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, @@ -547,7 +547,7 @@ class DaemonClient { errFd === null ? null : EMRGD_START_ERR() ); } - // G125:spawn 设 cwd=project_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills) + // G125:spawn 设 cwd=agent_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills) const python = this._findPython(); const args = ["-m", "emrg.server"]; this.logger.info(`[gui] spawning daemon: ${python} ${args.join(" ")} cwd=${os.homedir()}`); @@ -1172,7 +1172,7 @@ class DaemonClient { // both (`app.py:1345` / `:1353`), so this layer was the only client that // dropped them: they matched no branch, fell to the unknown-frame log at // the bottom, and reached no renderer. The catch-all's list of *expected* - // drops (`templates_list` / `compact_result` / `projects_list`) never named + // drops (`templates_list` / `compact_result` / `agents_list`) never named // them. this._emit(frame.type, frame); return; @@ -1200,7 +1200,7 @@ class DaemonClient { // 日志以 `type=` 开头,下一次可以 `grep 'unknown frame type=' | sort | uniq -c` // 直接数,而不必从截断的 JSON 里抠类型。 // 注意:本支对某些帧是**预期**的——daemon 会把 `templates_list` / `compact_result` - // / `projects_list` 广播给全部订阅者(`daemon.py:1518`),而这是别的会话发出的 + // / `agents_list` 广播给全部订阅者(`daemon.py:1518`),而这是别的会话发出的 // 命令的回执,本连接既不认领也不该转发。 const unknownType = frame.type === undefined ? "" : String(frame.type); this.logger.warn(`[gui] unknown frame type=${unknownType}: ${JSON.stringify(frame).slice(0, 200)}`); diff --git a/emrg/gui/gui-state.js b/emrg/gui/gui-state.js index 0b2703711..47607c912 100644 --- a/emrg/gui/gui-state.js +++ b/emrg/gui/gui-state.js @@ -8,7 +8,7 @@ // path (restore on boot + sidebar UI) lands in P4 slice 2. // // Design notes (from the rant): -// - Open sessions = cross-project tabs (aligned with the TUI multi-open model). +// - Open sessions = cross-agent tabs (aligned with the TUI multi-open model). // - Cap 20: never persist more than the cap; restore (slice 2) takes the most // recent 20 by lastActive. // - Write is atomic (.tmp + rename) so a crash mid-write never corrupts state. diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 46e173013..d370d9b4e 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -576,8 +576,8 @@ vision = false } // P6(rant 15:07:19 边界):projectPath 校验(跨项目打开时传项目路径) if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) { - logger.warn(`[gui:switch] REJECT invalid project path=${projectPath}`); - throw new Error("invalid project path"); + logger.warn(`[gui:switch] REJECT invalid agent path=${projectPath}`); + throw new Error("invalid agent path"); } // P6(rant 15:07:19 上限 20):显式打开新会话超限 → 提示不自动关(已打开 sid 复用不拦) if (openSessions.size >= DEFAULT_CAP && !openSessions.has(sessionId)) { @@ -669,7 +669,7 @@ vision = false ipcMain.handle("emrg:newSession", async (_e, { projectPath } = {}) => { // P6(rant 15:07:19 边界):projectPath 校验(新建会话指定项目时) if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) { - throw new Error("invalid project path"); + throw new Error("invalid agent path"); } // G14/G81:本地生成 session_id(无 new_session 消息) const sid = generateSessionId(); @@ -747,7 +747,7 @@ vision = false // // `cwd` resolves from the session exactly as `emrg:listHistory` does (rant // 2026-08-25T17:38:56): a hardcoded DEFAULT_CWD reads every session from the - // wrong project and answers an empty list that looks like "nothing typed yet". + // wrong agent and answers an empty list that looks like "nothing typed yet". if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); const payload = { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }; const frame = await requireConn().sendCommandAndWait("list_input_history", payload, 5000); @@ -782,9 +782,9 @@ vision = false return { ok: true, removedCount: frame.removed_count ?? 0 }; }); - ipcMain.handle("emrg:listMemories", async (_e, { scope = "project", sessionId } = {}) => { + ipcMain.handle("emrg:listMemories", async (_e, { scope = "agent", sessionId } = {}) => { // GUI / 指令 P3:/memory — 列出记忆(daemon list_memories → memories_list) - // 记忆命令同样按会话真实项目取 cwd(scope=session 时用该会话,project 时用当前会话) + // 记忆命令同样按会话真实项目取 cwd(scope=session 时用该会话,agent 时用当前会话) const memCwd = resolveSessionCwd(sessionId || currentSessionId) || DEFAULT_CWD; const params = { scope, cwd: memCwd }; if (scope === "session") { @@ -795,7 +795,7 @@ vision = false return frame.memories || []; }); - ipcMain.handle("emrg:readMemory", async (_e, { memoryId, scope = "project", sessionId } = {}) => { + ipcMain.handle("emrg:readMemory", async (_e, { memoryId, scope = "agent", sessionId } = {}) => { // GUI / 指令 P3:/memory — 读取单条记忆(daemon read_memory → memory_content) if (typeof memoryId !== "string" || !memoryId.trim()) throw new Error("invalid memory_id"); const params = { scope, memory_id: memoryId.trim(), cwd: resolveSessionCwd(sessionId || currentSessionId) || DEFAULT_CWD }; @@ -860,11 +860,11 @@ vision = false ipcMain.handle("emrg:listSkills", async () => { // GUI / 指令 P3:/skills — 读取技能列表(TUI 本地 load_skills 等价物,daemon 无协议) - // 技能在 ~/.emrg/skills/*.md(user)与 /.emrg/skills/*.md(project) + // 技能在 ~/.emrg/skills/*.md(user)与 /.emrg/skills/*.md(agent) const skills = []; const dirs = [ { dir: path.join(os.homedir(), ".emrg", "skills"), source: "user" }, - { dir: path.join(DEFAULT_CWD, ".emrg", "skills"), source: "project" }, + { dir: path.join(DEFAULT_CWD, ".emrg", "skills"), source: "agent" }, ]; for (const { dir, source } of dirs) { let files = []; @@ -883,40 +883,40 @@ vision = false return skills; }); - ipcMain.handle("emrg:listProjects", async () => { - // GUI / 指令 P4:/rant 项目下拉 — daemon list_projects → projects_list - const frame = await requireConn().sendCommandAndWait("list_projects", {}, 5000); - return frame.projects || []; + ipcMain.handle("emrg:listAgents", async () => { + // GUI / 指令 P4:/rant 项目下拉 — daemon list_agents → agents_list + const frame = await requireConn().sendCommandAndWait("list_agents", {}, 5000); + return frame.agents || []; }); // P5(rant 15:07:19):某项目的会话列表(list_sessions(cwd=projectPath)) - ipcMain.handle("emrg:listProjectSessions", async (_e, { projectPath }) => { - if (typeof projectPath !== "string" || !projectPath) throw new Error("invalid project path"); + ipcMain.handle("emrg:listAgentSessions", async (_e, { projectPath }) => { + if (typeof projectPath !== "string" || !projectPath) throw new Error("invalid agent path"); const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd: projectPath }, 5000); return { sessions: frame.sessions || [] }; }); - // P5:新建项目 = 轻量命令带 cwd → daemon 隐式 _touch_project 注册(零改动) - ipcMain.handle("emrg:registerProject", async (_e, { path: p }) => { - if (typeof p !== "string" || !p) throw new Error("invalid project path"); + // P5:新建项目 = 轻量命令带 cwd → daemon 隐式 _touch_agent 注册(零改动) + ipcMain.handle("emrg:registerAgent", async (_e, { path: p }) => { + if (typeof p !== "string" || !p) throw new Error("invalid agent path"); try { fs.accessSync(p, fs.constants.W_OK); // 目录可写校验(G121) } catch { - throw new Error("project directory not writable"); + throw new Error("agent directory not writable"); } await requireConn().sendCommandAndWait("list_sessions", { cwd: p }, 5000); // 隐式注册 return { ok: true, path: p }; }); // P5 slice 2:删除项目 = 只删 agents.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话 - // 受保护项目不可删除(内置 project emrg / 内置 task emrg-task → 演化依赖,删了悬空); - // `.emrg` 是 _touch_project 历史记录**非内置 → 可删**(再次访问重新注册)。 + // 受保护项目不可删除(内置 agent emrg / 内置 task emrg-task → 演化依赖,删了悬空); + // `.emrg` 是 _touch_agent 历史记录**非内置 → 可删**(再次访问重新注册)。 // 返回 { ok, removed, closed: [sids] };renderer 收到后负责切换激活会话。 - ipcMain.handle("emrg:removeProject", async (_e, { name, path: p } = {}) => { - if (typeof name !== "string" || !name.trim()) throw new Error("invalid project name"); - // 受保护项目:内置 project `emrg` + 内置 task `emrg-task` + ipcMain.handle("emrg:removeAgent", async (_e, { name, path: p } = {}) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid agent name"); + // 受保护项目:内置 agent `emrg` + 内置 task `emrg-task` if (name === "emrg" || name === "emrg-task") { - return { ok: false, protected: true, error: "protected system project" }; + return { ok: false, protected: true, error: "protected system agent" }; } // 该项目已打开的会话 → 关闭连接 + 移除 + 写盘(激活中先切相邻由 renderer 处理) const closed = []; @@ -930,7 +930,7 @@ vision = false if (currentSessionId && closed.includes(currentSessionId)) { currentSessionId = null; // 激活会话被关 → 无激活(renderer 切相邻) } - const frame = await requireConn().sendCommandAndWait("remove_project", { name: name.trim() }, 5000); + const frame = await requireConn().sendCommandAndWait("remove_agent", { name: name.trim() }, 5000); schedulePersistGuiState(); broadcastOpenSessions(); return { ok: true, removed: Boolean(frame.removed), closed, name }; @@ -968,15 +968,15 @@ vision = false // ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ────────── ipcMain.handle("emrg:taskCreate", async (_e, payload) => { - const { name, type, project, interval, enabled, repo, description, sandbox, schedule } = payload || {}; + const { name, type, agent, interval, enabled, repo, description, sandbox, schedule } = payload || {}; if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); - if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); + if (typeof agent !== "string" || !agent.trim()) throw new Error("invalid agent"); // `schedule` 必须原样转发:这张固定字段表此前把它丢掉,于是 GUI 永远只能建 // 单一频次的任务,daemon 收了也白收(issue #2490)。只有 payload 真的带了这个 // 键才发,缺席=不声明,与 task_update 的「缺省不动该字段」一致。 const fields = { - name: name.trim(), task_type: type.trim(), project: project.trim(), + name: name.trim(), task_type: type.trim(), agent: agent.trim(), interval, enabled, repo, description, sandbox, }; if ("schedule" in (payload || {})) fields.schedule = schedule; @@ -1037,12 +1037,12 @@ vision = false return frame.rants || []; }); - ipcMain.handle("emrg:sendRant", async (_e, { message, project = "" } = {}) => { + ipcMain.handle("emrg:sendRant", async (_e, { message, agent = "" } = {}) => { // GUI / 指令 P4:/rant — 提交反馈到演化系统(daemon rant 协议,字段序与 rants.jsonl 一致) if (typeof message !== "string" || !message.trim()) throw new Error("invalid rant message"); const frame = await requireConn().sendCommandAndWait("rant", { message: message.trim().slice(0, 10000), - project: String(project || "").trim(), + agent: String(agent || "").trim(), // timestamp deliberately NOT sent: daemon stamps rants with local time // (rant 2026-08-07T13:34Z — GUI previously sent new Date().toISOString(), // which is UTC and 8h behind on UTC+8 hosts) @@ -1259,7 +1259,7 @@ vision = false return { ok: true }; }); - ipcMain.handle("emrg:pickProjectDir", async () => { + ipcMain.handle("emrg:pickAgentDir", async () => { // G70/G115:只选不写盘(保存才落盘) const res = await dialog.showOpenDialog(win, { properties: ["openDirectory"] }); return res.canceled ? null : res.filePaths[0]; diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index a59f84886..30ac9aea9 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -50,10 +50,10 @@ const api = { panelResized: (payload) => ipcRenderer.invoke("emrg:panelResized", payload), // P2.3:面板布局上报 → bounds 同步 getPreviewState: () => ipcRenderer.invoke("emrg:getPreviewState"), // P2.3:renderer 崩溃恢复拉取 listSkills: () => ipcRenderer.invoke("emrg:listSkills"), - listProjects: () => ipcRenderer.invoke("emrg:listProjects"), - listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload), - registerProject: (payload) => ipcRenderer.invoke("emrg:registerProject", payload), - removeProject: (payload) => ipcRenderer.invoke("emrg:removeProject", payload), + listAgents: () => ipcRenderer.invoke("emrg:listAgents"), + listAgentSessions: (payload) => ipcRenderer.invoke("emrg:listAgentSessions", payload), + registerAgent: (payload) => ipcRenderer.invoke("emrg:registerAgent", payload), + removeAgent: (payload) => ipcRenderer.invoke("emrg:removeAgent", payload), listTasks: () => ipcRenderer.invoke("emrg:listTasks"), // 间隔下限由 daemon 上报(issue #2490)——渲染层不留第二份数字 taskLimits: () => ipcRenderer.invoke("emrg:taskLimits"), @@ -79,7 +79,7 @@ const api = { saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), getSettings: () => ipcRenderer.invoke("emrg:getSettings"), cancel: (sessionId) => ipcRenderer.invoke("emrg:cancel", sessionId), - pickProjectDir: () => ipcRenderer.invoke("emrg:pickProjectDir"), + pickAgentDir: () => ipcRenderer.invoke("emrg:pickAgentDir"), // 渲染进程日志桥(rant 2026-08-24:打开会话链路排障——renderer 点击/调用步骤 // 经 emrg:log IPC 上报,由 main 写入 ~/.emrg/emrg-gui.log,与 main 日志同文件时序对齐) log: (level, msg) => ipcRenderer.invoke("emrg:log", { level, msg }), diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 4362c6071..eaafd8857 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -930,8 +930,8 @@ dialog::backdrop { .rant-row .rant-col-time { flex: 0 0 150px; } -.rant-head .rant-col-project, -.rant-row .rant-col-project { +.rant-head .rant-col-agent, +.rant-row .rant-col-agent { flex: 0 0 90px; } .rant-head .rant-col-status, @@ -950,7 +950,7 @@ dialog::backdrop { } .rant-head span, .rant-row .rant-col-time, -.rant-row .rant-col-project, +.rant-row .rant-col-agent, .rant-row .rant-col-progress, .rant-row .rant-col-content { overflow: hidden; @@ -958,7 +958,7 @@ dialog::backdrop { white-space: nowrap; } .rant-row .rant-col-time, -.rant-row .rant-col-project, +.rant-row .rant-col-agent, .rant-row .rant-col-progress, .rant-row .rant-col-content { font-size: var(--fs-aux); diff --git a/emrg/gui/renderer/package-lock.json b/emrg/gui/renderer/package-lock.json index 80b364445..2527e4ab5 100644 --- a/emrg/gui/renderer/package-lock.json +++ b/emrg/gui/renderer/package-lock.json @@ -313,9 +313,9 @@ "dev": true, "license": "MIT" }, - "node_modules/@oxc-project/types": { + "node_modules/@oxc-agent/types": { "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "resolved": "https://registry.npmjs.org/@oxc-agent/types/-/types-0.146.0.tgz", "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", "dev": true, "license": "MIT", @@ -2816,7 +2816,7 @@ "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.146.0", + "@oxc-agent/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { diff --git a/emrg/gui/renderer/src/components/DialogHost.test.tsx b/emrg/gui/renderer/src/components/DialogHost.test.tsx index 901a251de..a0236ccf3 100644 --- a/emrg/gui/renderer/src/components/DialogHost.test.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.test.tsx @@ -66,11 +66,11 @@ function mockEmrg(over: Record = {}) { return mock; }; const bridge = { - listProjects: fn("listProjects", [{ name: "emrg", path: "/p/emrg" }, { name: "demo", path: "/p/demo", latest_session_at: "2026-08-27T00:00:00Z" }]), - listProjectSessions: fn("listProjectSessions", { sessions: [{ session_id: "s9", title: "Demo session" }] }), + listAgents: fn("listAgents", [{ name: "emrg", path: "/p/emrg" }, { name: "demo", path: "/p/demo", latest_session_at: "2026-08-27T00:00:00Z" }]), + listAgentSessions: fn("listAgentSessions", { sessions: [{ session_id: "s9", title: "Demo session" }] }), newSession: fn("newSession", { session_id: "s-new" }), - pickProjectDir: fn("pickProjectDir", { path: "/p/new" }), - registerProject: fn("registerProject", { ok: true, path: "/p/new" }), + pickAgentDir: fn("pickAgentDir", { path: "/p/new" }), + registerAgent: fn("registerAgent", { ok: true, path: "/p/new" }), switchSession: fn("switchSession", { ok: true }), deleteSession: fn("deleteSession", { ok: true }), renameSession: fn("renameSession", { ok: true }), @@ -81,7 +81,7 @@ function mockEmrg(over: Record = {}) { listMemories: fn("listMemories", [{ id: "m1", title: "记忆一", summary: "摘要内容" }]), readMemory: fn("readMemory", { id: "m1", title: "记忆一", content: "详情正文" }), listSkills: fn("listSkills", [{ name: "browser-harness", description: "web automation", source: "user" }]), - removeProject: fn("removeProject", { ok: true }), + removeAgent: fn("removeAgent", { ok: true }), ...over, }; (window as unknown as { emrg?: unknown }).emrg = bridge; @@ -147,7 +147,7 @@ describe("DialogHost (Batch 5 slice 4)", () => { await waitFor(() => expect(screen.getByText("记忆一")).toBeInTheDocument()); fireEvent.click(screen.getByText("记忆一")); await waitFor(() => expect(screen.getByTestId("memory-detail")).toBeInTheDocument()); - expect(calls.readMemory[0][0]).toMatchObject({ memoryId: "m1", scope: "project" }); + expect(calls.readMemory[0][0]).toMatchObject({ memoryId: "m1", scope: "agent" }); }); it("记忆详情按 vanilla 截断:title 80 / body 2000", async () => { @@ -277,7 +277,7 @@ describe("DialogHost (Batch 5 slice 4)", () => { await waitFor(() => expect(screen.getByTestId("open-session-dialog")).toBeInTheDocument()); await waitFor(() => expect(screen.getByText("demo")).toBeInTheDocument()); fireEvent.click(screen.getByText("demo")); - await waitFor(() => expect(calls.listProjectSessions.length).toBe(1)); + await waitFor(() => expect(calls.listAgentSessions.length).toBe(1)); await waitFor(() => expect(screen.getByText("Demo session")).toBeInTheDocument()); fireEvent.click(screen.getByText("Demo session")); await waitFor(() => expect(calls.switchSession.length).toBe(1)); @@ -292,7 +292,7 @@ describe("DialogHost (Batch 5 slice 4)", () => { const del = screen.getAllByTestId("open-session-delete"); fireEvent.click(del[0]); // emrg 项目 await waitFor(() => expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument()); - expect(screen.getByText(/system project/i)).toBeInTheDocument(); + expect(screen.getByText(/system agent/i)).toBeInTheDocument(); }); it("新建会话:点选项目 → newSession + onSwitchSession(新会话)", async () => { diff --git a/emrg/gui/renderer/src/components/DialogHost.tsx b/emrg/gui/renderer/src/components/DialogHost.tsx index a8ba898db..7475baedc 100644 --- a/emrg/gui/renderer/src/components/DialogHost.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.tsx @@ -4,10 +4,10 @@ import { COMMANDS, hintText } from "../lib/commands"; import { helpRows, type MemoryRow, type SkillRow } from "../lib/dialogLists"; import { rewindPointRow, type RewindPoint } from "../lib/rewind"; import { - isProtectedProject, - projectRowView, + isProtectedAgent, + agentRowView, sessionRowView, - type ProjectRow, + type AgentRow, type SessionRow, } from "../lib/openSession"; import { dialogReducer, initialDialogState } from "../lib/dialog"; @@ -42,11 +42,11 @@ import { SandboxRootsDialog } from "./SandboxRootsDialog"; /** DialogHost 用到的 preload 桥方法(与 preload.js 的 52 通道子集对齐) */ interface EmrgBridge { - listProjects(): Promise; - listProjectSessions(p: { projectPath: string }): Promise<{ sessions: SessionRow[] }>; + listAgents(): Promise; + listAgentSessions(p: { projectPath: string }): Promise<{ sessions: SessionRow[] }>; newSession(p: { projectPath: string }): Promise<{ session_id: string }>; - pickProjectDir(): Promise<{ path?: string } | null>; - registerProject(p: { path: string }): Promise<{ ok: boolean; path: string }>; + pickAgentDir(): Promise<{ path?: string } | null>; + registerAgent(p: { path: string }): Promise<{ ok: boolean; path: string }>; switchSession(p: { sessionId: string }): Promise; deleteSession(p: { sessionId: string }): Promise; renameSession(p: { sessionId: string; title: string }): Promise; @@ -54,14 +54,14 @@ interface EmrgBridge { compactSession(p: { sessionId: string }): Promise; listHistory(p: { sessionId: string }): Promise<{ messages: RewindPoint[]; hasMore?: boolean }>; rewindSession(p: { sessionId: string; recordIndex: number }): Promise; - listMemories(p: { scope: "session" | "project"; sessionId?: string }): Promise; - readMemory(p: { memoryId: string; scope: "session" | "project"; sessionId?: string }): Promise<{ + listMemories(p: { scope: "session" | "agent"; sessionId?: string }): Promise; + readMemory(p: { memoryId: string; scope: "session" | "agent"; sessionId?: string }): Promise<{ id?: string; title?: string; content?: string; }>; listSkills(): Promise; - removeProject(p: { name: string; path: string }): Promise<{ ok: boolean; error?: string }>; + removeAgent(p: { name: string; path: string }): Promise<{ ok: boolean; error?: string }>; } function bridge(): EmrgBridge | undefined { @@ -144,11 +144,11 @@ export const DialogHost = forwardRef(function // confirm/rename 走 lib/dialog 的 reducer(vanilla showConfirm/showRename 请求槽) const [dialogState, dispatch] = useReducer(dialogReducer, initialDialogState); const [listDialog, setListDialog] = useState(CLOSED); - const [memoryScope, setMemoryScope] = useState<"session" | "project">("project"); + const [memoryScope, setMemoryScope] = useState<"session" | "agent">("agent"); // 列表数据(null = loading) - const [projects, setProjects] = useState(null); - const [openStep, setOpenStep] = useState("projects"); + const [agents, setAgents] = useState(null); + const [openStep, setOpenStep] = useState("agents"); const [openSessions, setOpenSessions] = useState(null); const [openProjectName, setOpenProjectName] = useState(""); const [sessionsError, setSessionsError] = useState(null); @@ -172,17 +172,17 @@ export const DialogHost = forwardRef(function // ── 数据加载(打开即拉取;bridge 缺失时 no-op 降级) ── - // projects:open-session / new-session 共用 + // agents:open-session / new-session 共用 useEffect(() => { if (!listDialog.sessions && !listDialog.newSession) return; const b = bridge(); if (!b) return; let cancelled = false; - setProjects(null); + setAgents(null); setSessionsError(null); - b.listProjects() + b.listAgents() .then((rows) => { - if (!cancelled) setProjects(rows); + if (!cancelled) setAgents(rows); }) .catch((e) => { if (!cancelled) setSessionsError((e as Error)?.message ?? String(e)); @@ -192,7 +192,7 @@ export const DialogHost = forwardRef(function }; }, [listDialog.sessions, listDialog.newSession]); - // memories(/memory [session|project]) + // memories(/memory [session|agent]) useEffect(() => { if (!listDialog.memory) return; const b = bridge(); @@ -256,7 +256,7 @@ export const DialogHost = forwardRef(function // ── 命令路由动作 ── function openMemory(scopeArg?: string): void { - setMemoryScope(scopeArg === "session" ? "session" : "project"); + setMemoryScope(scopeArg === "session" ? "session" : "agent"); openDialog("memory"); } @@ -323,7 +323,7 @@ export const DialogHost = forwardRef(function } /** 打开会话两步流:项目 → 会话(vanilla showOpenSessionDialog) */ - async function pickProject(p: ProjectRow): Promise { + async function pickProject(p: AgentRow): Promise { const b = bridge(); if (!b || !p.path) return; setOpenStep("sessions"); @@ -331,21 +331,21 @@ export const DialogHost = forwardRef(function setOpenSessions(null); setSessionsError(null); try { - const res = await b.listProjectSessions({ projectPath: p.path }); + const res = await b.listAgentSessions({ projectPath: p.path }); setOpenSessions(res.sessions ?? []); } catch (e) { setSessionsError((e as Error)?.message ?? String(e)); } } - /** 删除项目:受保护 → 拒绝提示;否则确认后 removeProject + 重载列表 */ - function deleteProject(p: ProjectRow): void { - if (isProtectedProject(p)) { + /** 删除项目:受保护 → 拒绝提示;否则确认后 removeAgent + 重载列表 */ + function deleteAgent(p: AgentRow): void { + if (isProtectedAgent(p)) { dispatch({ type: "open-confirm", payload: { - title: t("deleteProject.title"), - message: t("deleteProject.protectedBody", { name: String(p.name || p.path || "") }), + title: t("deleteAgent.title"), + message: t("deleteAgent.protectedBody", { name: String(p.name || p.path || "") }), okText: "dlg.gotIt", danger: false, }, @@ -355,36 +355,36 @@ export const DialogHost = forwardRef(function dispatch({ type: "open-confirm", payload: { - title: t("deleteProject.title"), - message: t("deleteProject.body", { name: String(p.name || p.path || "") }), + title: t("deleteAgent.title"), + message: t("deleteAgent.body", { name: String(p.name || p.path || "") }), okText: "dlg.delete", danger: true, onOk: async () => { const b = bridge(); if (!b) return; - const res = await b.removeProject({ name: p.name || "", path: p.path || "" }); + const res = await b.removeAgent({ name: p.name || "", path: p.path || "" }); if (res && res.error) { - transcript.addSystemMessage(t("deleteProject.failed", { msg: String(res.error) }), sid); + transcript.addSystemMessage(t("deleteAgent.failed", { msg: String(res.error) }), sid); } else { - transcript.addSystemMessage(t("deleteProject.removed", { name: String(p.name || p.path || "") }), sid); - setProjects(null); // 重载项目列表 + transcript.addSystemMessage(t("deleteAgent.removed", { name: String(p.name || p.path || "") }), sid); + setAgents(null); // 重载项目列表 } }, }, }); } - /** 新建项目 = 选目录 → 注册 → 重载项目列表(vanilla new-session 的 new-project 路径) */ - async function newProject(): Promise { + /** 新建项目 = 选目录 → 注册 → 重载项目列表(vanilla new-session 的 new-agent 路径) */ + async function newAgent(): Promise { const b = bridge(); if (!b) return; try { - const res = await b.pickProjectDir(); + const res = await b.pickAgentDir(); if (!res?.path) return; - await b.registerProject({ path: res.path }); - setProjects(null); - const rows = await b.listProjects(); - setProjects(rows); + await b.registerAgent({ path: res.path }); + setAgents(null); + const rows = await b.listAgents(); + setAgents(rows); } catch (e) { setSessionsError((e as Error)?.message ?? String(e)); } @@ -444,7 +444,7 @@ export const DialogHost = forwardRef(function openRename, openDelete, openSessions: () => { - setOpenStep("projects"); + setOpenStep("agents"); openDialog("sessions"); }, openNewSession: () => openDialog("newSession"), @@ -517,20 +517,20 @@ export const DialogHost = forwardRef(function { const b = bridge(); if (b) await b.switchSession({ sessionId: s.session_id }); onSwitchSession(s.session_id); closeDialog("sessions"); }} - onDeleteProject={deleteProject} - onNewProject={newProject} + onDeleteAgent={deleteAgent} + onNewAgent={newAgent} onNewSession={() => { closeDialog("sessions"); openDialog("newSession"); @@ -539,12 +539,12 @@ export const DialogHost = forwardRef(function /> { + onPickAgent={(p) => { if (p.path) void createSession(p.path); }} - onNewProject={newProject} + onNewAgent={newAgent} onDismiss={() => closeDialog("newSession")} /> (function }); // 类型 re-export(测试/调用方构造行视图用) -export { projectRowView, sessionRowView, rewindPointRow }; +export { agentRowView, sessionRowView, rewindPointRow }; diff --git a/emrg/gui/renderer/src/components/MemoryDialog.test.tsx b/emrg/gui/renderer/src/components/MemoryDialog.test.tsx index 5f01ed89a..cf63efcbc 100644 --- a/emrg/gui/renderer/src/components/MemoryDialog.test.tsx +++ b/emrg/gui/renderer/src/components/MemoryDialog.test.tsx @@ -38,7 +38,7 @@ describe("MemoryDialog", () => { expect(screen.getByTestId("memory-loading")).toHaveTextContent("加载中"); }); - it("空列表 → app.noMemories(project scope 文案)", () => { + it("空列表 → app.noMemories(agent scope 文案)", () => { setup({ memories: [] }); expect(screen.getByTestId("memory-empty")).toHaveTextContent("项目"); }); diff --git a/emrg/gui/renderer/src/components/MemoryDialog.tsx b/emrg/gui/renderer/src/components/MemoryDialog.tsx index 0078d9b0f..fb5758fae 100644 --- a/emrg/gui/renderer/src/components/MemoryDialog.tsx +++ b/emrg/gui/renderer/src/components/MemoryDialog.tsx @@ -7,7 +7,7 @@ import { DialogActions } from "./HelpDialog"; * MemoryDialog — /memory 记忆浏览器对话框(Batch 4 slice 2)。 * 镜像 vanilla `showMemoryDialog(sub)`: * - loading(memories=null)→ dlg.loading 行 - * - 空列表 → app.noMemories(scope 区分 session/project 文案) + * - 空列表 → app.noMemories(scope 区分 session/agent 文案) * - 行 = 标题(40 截断) + 摘要(50 截断);点击 → onSelect(id)(vanilla 内联 readMemory) * - detail 传入 → 显示详情块(title + pre body) * - error → app.memFailed @@ -22,7 +22,7 @@ export interface MemoryDialogProps { open: boolean; /** null = loading;[] = 空;有值 = 列表 */ memories?: MemoryRow[] | null; - scope?: "session" | "project"; + scope?: "session" | "agent"; error?: string | null; detail?: MemoryDetailView | null; onSelect?: (id: string) => void; @@ -32,7 +32,7 @@ export interface MemoryDialogProps { export function MemoryDialog({ open, memories = null, - scope = "project", + scope = "agent", error = null, detail = null, onSelect, diff --git a/emrg/gui/renderer/src/components/NewSessionDialog.test.tsx b/emrg/gui/renderer/src/components/NewSessionDialog.test.tsx index e10a9ed61..fa1bafb0f 100644 --- a/emrg/gui/renderer/src/components/NewSessionDialog.test.tsx +++ b/emrg/gui/renderer/src/components/NewSessionDialog.test.tsx @@ -3,21 +3,21 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { NewSessionDialog } from "./NewSessionDialog"; import { I18nProvider } from "../lib/i18n"; -import type { ProjectRow } from "../lib/openSession"; +import type { AgentRow } from "../lib/openSession"; /** * NewSessionDialog.test.tsx — 新建会话对话框测试(Batch 4 slice 3)。 * 镜像 vanilla showNewSessionDialog:loading/空/error/列表 + 新建项目按钮。 */ -const projects: ProjectRow[] = [ +const agents: AgentRow[] = [ { name: "emrg", path: "/x/emrg", latest_session_at: new Date().toISOString() }, ]; function setup(props: Partial[0]> = {}) { return render( - + , ); } @@ -33,12 +33,12 @@ describe("NewSessionDialog", () => { }); it("loading → dlg.loading 行", () => { - setup({ projects: null }); + setup({ agents: null }); expect(screen.getByTestId("new-session-loading")).toHaveTextContent("加载中"); }); - it("空项目列表 → noProjects", () => { - setup({ projects: [] }); + it("空项目列表 → noAgents", () => { + setup({ agents: [] }); expect(screen.getByTestId("new-session-empty")).toHaveTextContent("还没有项目"); }); @@ -47,21 +47,21 @@ describe("NewSessionDialog", () => { expect(screen.getByTestId("new-session-error")).toHaveTextContent("加载失败:boom"); }); - it("项目行:name/path/活跃;点击 → onPickProject", async () => { - const onPickProject = vi.fn(); - setup({ onPickProject }); - const row = screen.getByTestId("new-session-project"); + it("项目行:name/path/活跃;点击 → onPickAgent", async () => { + const onPickAgent = vi.fn(); + setup({ onPickAgent }); + const row = screen.getByTestId("new-session-agent"); expect(row).toHaveTextContent("emrg"); expect(row).toHaveTextContent("/x/emrg"); await userEvent.click(row); - expect(onPickProject).toHaveBeenCalledWith(projects[0]); + expect(onPickAgent).toHaveBeenCalledWith(agents[0]); }); - it("新建项目按钮 → onNewProject", async () => { - const onNewProject = vi.fn(); - setup({ onNewProject }); - await userEvent.click(screen.getByTestId("new-session-new-project")); - expect(onNewProject).toHaveBeenCalledTimes(1); + it("新建项目按钮 → onNewAgent", async () => { + const onNewAgent = vi.fn(); + setup({ onNewAgent }); + await userEvent.click(screen.getByTestId("new-session-new-agent")); + expect(onNewAgent).toHaveBeenCalledTimes(1); }); it("关闭按钮 → onDismiss", async () => { diff --git a/emrg/gui/renderer/src/components/NewSessionDialog.tsx b/emrg/gui/renderer/src/components/NewSessionDialog.tsx index 276b7cd3d..a7163f66f 100644 --- a/emrg/gui/renderer/src/components/NewSessionDialog.tsx +++ b/emrg/gui/renderer/src/components/NewSessionDialog.tsx @@ -1,32 +1,32 @@ import { useI18n } from "../lib/i18n"; -import { projectRowView, type ProjectRow } from "../lib/openSession"; +import { agentRowView, type AgentRow } from "../lib/openSession"; import { Dialog } from "./Dialog"; import { DialogActions } from "./HelpDialog"; /** * NewSessionDialog — 新建会话对话框(Batch 4 slice 3)。 * 镜像 vanilla `showNewSessionDialog()`: - * - loading(projects=null)/ empty(noProjects)/ error(loadFailed)四态 - * - 项目行(name/path/最近活跃)→ 点击 onPickProject(p)(vanilla: newSession({projectPath})) - * - 底部 "+ 新建项目…" → onNewProject(vanilla: pickProjectDir → registerProject → newSession) - * 数据注入式:projects 与回调由调用方(Batch 5 接线 window.emrg.listProjects/pickProjectDir)提供。 + * - loading(agents=null)/ empty(noAgents)/ error(loadFailed)四态 + * - 项目行(name/path/最近活跃)→ 点击 onPickAgent(p)(vanilla: newSession({projectPath})) + * - 底部 "+ 新建项目…" → onNewAgent(vanilla: pickAgentDir → registerAgent → newSession) + * 数据注入式:agents 与回调由调用方(Batch 5 接线 window.emrg.listAgents/pickAgentDir)提供。 */ export interface NewSessionDialogProps { open: boolean; /** null = loading;[] = 空;有值 = 列表 */ - projects?: ProjectRow[] | null; + agents?: AgentRow[] | null; error?: string | null; - onPickProject?: (p: ProjectRow) => void; - onNewProject?: () => void; + onPickAgent?: (p: AgentRow) => void; + onNewAgent?: () => void; onDismiss?: () => void; } export function NewSessionDialog({ open, - projects = null, + agents = null, error = null, - onPickProject, - onNewProject, + onPickAgent, + onNewAgent, onDismiss, }: NewSessionDialogProps) { const { t } = useI18n(); @@ -39,29 +39,29 @@ export function NewSessionDialog({
); } - if (projects === null) { + if (agents === null) { return (
{t("dlg.loading")}
); } - if (projects.length === 0) { + if (agents.length === 0) { return (
- {t("openSession.noProjects")} + {t("openSession.noAgents")}
); } - return projects.map((p) => { - const v = projectRowView(p, t); + return agents.map((p) => { + const v = agentRowView(p, t); return (
-
diff --git a/emrg/gui/renderer/src/components/OpenSessionDialog.test.tsx b/emrg/gui/renderer/src/components/OpenSessionDialog.test.tsx index d50948d3f..5c8f63ed1 100644 --- a/emrg/gui/renderer/src/components/OpenSessionDialog.test.tsx +++ b/emrg/gui/renderer/src/components/OpenSessionDialog.test.tsx @@ -3,14 +3,14 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { OpenSessionDialog } from "./OpenSessionDialog"; import { I18nProvider } from "../lib/i18n"; -import type { ProjectRow, SessionRow } from "../lib/openSession"; +import type { AgentRow, SessionRow } from "../lib/openSession"; /** * OpenSessionDialog.test.tsx — 两步打开会话对话框测试(Batch 4 slice 3)。 - * 镜像 vanilla showOpenSessionDialog/showProjectSessions:项目列表(含删除)→ 会话列表。 + * 镜像 vanilla showOpenSessionDialog/showAgentSessions:项目列表(含删除)→ 会话列表。 */ -const projects: ProjectRow[] = [ +const agents: AgentRow[] = [ { name: "emrg", path: "/x/emrg", latest_session_at: new Date().toISOString() }, { name: "aitokenpool", path: "/x/aitokenpool" }, ]; @@ -23,7 +23,7 @@ const sessions: SessionRow[] = [ function setup(props: Partial[0]> = {}) { return render( - + , ); } @@ -32,19 +32,19 @@ describe("OpenSessionDialog", () => { it("open=false → 不渲染", () => { render( - + , ); expect(screen.queryByTestId("open-session-dialog")).toBeNull(); }); - it("projects loading → dlg.loading 行", () => { - setup({ projects: null }); + it("agents loading → dlg.loading 行", () => { + setup({ agents: null }); expect(screen.getByTestId("open-session-loading")).toHaveTextContent("加载中"); }); - it("空项目列表 → noProjects", () => { - setup({ projects: [] }); + it("空项目列表 → noAgents", () => { + setup({ agents: [] }); expect(screen.getByTestId("open-session-empty")).toHaveTextContent("还没有项目"); }); @@ -53,22 +53,22 @@ describe("OpenSessionDialog", () => { expect(screen.getByTestId("open-session-error")).toHaveTextContent("加载失败:boom"); }); - it("项目行:name/path/活跃 + 删除按钮;点击 → onPickProject", async () => { - const onPickProject = vi.fn(); - setup({ onPickProject }); - const rows = screen.getAllByTestId("open-session-project"); + it("项目行:name/path/活跃 + 删除按钮;点击 → onPickAgent", async () => { + const onPickAgent = vi.fn(); + setup({ onPickAgent }); + const rows = screen.getAllByTestId("open-session-agent"); expect(rows).toHaveLength(2); expect(rows[0]).toHaveTextContent("emrg"); expect(rows[0]).toHaveTextContent("/x/emrg"); await userEvent.click(screen.getAllByTestId("open-session-pick")[1]); - expect(onPickProject).toHaveBeenCalledWith(projects[1]); + expect(onPickAgent).toHaveBeenCalledWith(agents[1]); }); - it("删除按钮 → onDeleteProject", async () => { - const onDeleteProject = vi.fn(); - setup({ onDeleteProject }); + it("删除按钮 → onDeleteAgent", async () => { + const onDeleteAgent = vi.fn(); + setup({ onDeleteAgent }); await userEvent.click(screen.getAllByTestId("open-session-delete")[0]); - expect(onDeleteProject).toHaveBeenCalledWith(projects[0]); + expect(onDeleteAgent).toHaveBeenCalledWith(agents[0]); }); it("sessions 步:标题插值 + 会话行(当前标记)→ onPickSession", async () => { @@ -91,11 +91,11 @@ describe("OpenSessionDialog", () => { it("底部:新建会话 + 新建项目按钮 → 各自回调", async () => { const onNewSession = vi.fn(); - const onNewProject = vi.fn(); - setup({ onNewSession, onNewProject }); + const onNewAgent = vi.fn(); + setup({ onNewSession, onNewAgent }); await userEvent.click(screen.getByTestId("open-session-new-session")); expect(onNewSession).toHaveBeenCalledTimes(1); - await userEvent.click(screen.getByTestId("open-session-new-project")); - expect(onNewProject).toHaveBeenCalledTimes(1); + await userEvent.click(screen.getByTestId("open-session-new-agent")); + expect(onNewAgent).toHaveBeenCalledTimes(1); }); }); diff --git a/emrg/gui/renderer/src/components/OpenSessionDialog.tsx b/emrg/gui/renderer/src/components/OpenSessionDialog.tsx index 19fb55f2b..e73cba41b 100644 --- a/emrg/gui/renderer/src/components/OpenSessionDialog.tsx +++ b/emrg/gui/renderer/src/components/OpenSessionDialog.tsx @@ -1,35 +1,35 @@ import { useI18n } from "../lib/i18n"; -import { projectRowView, sessionRowView, type ProjectRow, type SessionRow } from "../lib/openSession"; +import { agentRowView, sessionRowView, type AgentRow, type SessionRow } from "../lib/openSession"; import { Dialog } from "./Dialog"; import { DialogActions } from "./HelpDialog"; /** * OpenSessionDialog — 两步打开会话对话框(Batch 4 slice 3)。 - * 镜像 vanilla `showOpenSessionDialog()` / `showProjectSessions()`: - * - step="projects":项目行(name/path/最近活跃 + 删除按钮)→ onPickProject(p) - * - step="sessions":标题 = 打开会话 — {project};会话行(title||id + 当前/活跃标记)→ onPickSession(s) - * - loading / empty / error 四态对齐 vanilla(dlg.loading / openSession.noProjects / noSessions / loadFailed) - * - 删除按钮 → onDeleteProject(p)(Batch 5 接线 confirmDeleteProject:受保护 → 拒绝;否则 ConfirmDialog) - * 数据注入式:step/projects/sessions 由调用方(Batch 5 接线 window.emrg.listProjects/listProjectSessions) + * 镜像 vanilla `showOpenSessionDialog()` / `showAgentSessions()`: + * - step="agents":项目行(name/path/最近活跃 + 删除按钮)→ onPickAgent(p) + * - step="sessions":标题 = 打开会话 — {agent};会话行(title||id + 当前/活跃标记)→ onPickSession(s) + * - loading / empty / error 四态对齐 vanilla(dlg.loading / openSession.noAgents / noSessions / loadFailed) + * - 删除按钮 → onDeleteAgent(p)(Batch 5 接线 confirmDeleteProject:受保护 → 拒绝;否则 ConfirmDialog) + * 数据注入式:step/agents/sessions 由调用方(Batch 5 接线 window.emrg.listAgents/listAgentSessions) * 驱动状态机。 */ -export type OpenSessionStep = "projects" | "sessions"; +export type OpenSessionStep = "agents" | "sessions"; export interface OpenSessionDialogProps { open: boolean; step: OpenSessionStep; /** null = loading;[] = 空;有值 = 列表 */ - projects?: ProjectRow[] | null; + agents?: AgentRow[] | null; /** step="sessions" 时的会话列表 */ sessions?: SessionRow[] | null; /** step="sessions" 时的当前项目名(标题插值) */ projectName?: string; currentSid?: string | null; error?: string | null; - onPickProject?: (p: ProjectRow) => void; + onPickAgent?: (p: AgentRow) => void; onPickSession?: (s: SessionRow) => void; - onDeleteProject?: (p: ProjectRow) => void; - onNewProject?: () => void; + onDeleteAgent?: (p: AgentRow) => void; + onNewAgent?: () => void; onNewSession?: () => void; onDismiss?: () => void; } @@ -37,20 +37,20 @@ export interface OpenSessionDialogProps { export function OpenSessionDialog({ open, step, - projects = null, + agents = null, sessions = null, projectName = "", currentSid = null, error = null, - onPickProject, + onPickAgent, onPickSession, - onDeleteProject, - onNewProject, + onDeleteAgent, + onNewAgent, onNewSession, onDismiss, }: OpenSessionDialogProps) { const { t } = useI18n(); - const title = step === "sessions" ? t("openSession.titleProject", { project: projectName || "…" }) : t("openSession.title"); + const title = step === "sessions" ? t("openSession.titleAgent", { agent: projectName || "…" }) : t("openSession.title"); const renderProjects = () => { if (error) { @@ -60,29 +60,29 @@ export function OpenSessionDialog({ ); } - if (projects === null) { + if (agents === null) { return (
{t("dlg.loading")}
); } - if (projects.length === 0) { + if (agents.length === 0) { return (
- {t("openSession.noProjects")} + {t("openSession.noAgents")}
); } - return projects.map((p) => { - const v = projectRowView(p, t); + return agents.map((p) => { + const v = agentRowView(p, t); return ( -
+
); @@ -151,14 +151,14 @@ export function OpenSessionDialog({ {t("openSession.desc")}

- {step === "projects" ? renderProjects() : renderSessions()} + {step === "agents" ? renderProjects() : renderSessions()}
-
diff --git a/emrg/gui/renderer/src/components/RantDialog.test.tsx b/emrg/gui/renderer/src/components/RantDialog.test.tsx index 75f279c39..a8827bd1e 100644 --- a/emrg/gui/renderer/src/components/RantDialog.test.tsx +++ b/emrg/gui/renderer/src/components/RantDialog.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { I18nProvider } from "../lib/i18n"; import { RantDialog } from "./RantDialog"; -import type { ProjectRec } from "../lib/workspaceView"; +import type { AgentRec } from "../lib/workspaceView"; /** * RantDialog.test.tsx — Batch 5 slice 8:Rant 提交对话框。 @@ -14,7 +14,7 @@ function wrapper(node: React.ReactNode) { return {node}; } -const PROJECTS: ProjectRec[] = [ +const AGENTS: AgentRec[] = [ { name: "emrg", path: "/p/emrg" }, { name: "sci", path: "/p/sci" }, ]; @@ -25,17 +25,17 @@ afterEach(() => { describe("RantDialog", () => { it("open:渲染消息文本域 + 项目下拉(已注册项目)", () => { - render(wrapper()); + render(wrapper()); expect(screen.getByTestId("rant-dialog")).toBeInTheDocument(); expect((screen.getByTestId("rant-message") as HTMLTextAreaElement).value).toBe(""); - const sel = screen.getByTestId("rant-project") as HTMLSelectElement; - expect(sel.options.length).toBe(PROJECTS.length + 1); // + 空选项 + const sel = screen.getByTestId("rant-agent") as HTMLSelectElement; + expect(sel.options.length).toBe(AGENTS.length + 1); // + 空选项 expect(sel.options[1].value).toBe("emrg"); }); it("空消息 → 内联错误,不提交", () => { const onSubmit = vi.fn(); - render(wrapper()); + render(wrapper()); fireEvent.click(screen.getByTestId("rant-submit")); expect(screen.getByTestId("rant-error")).toHaveTextContent("required"); expect(onSubmit).not.toHaveBeenCalled(); @@ -43,17 +43,17 @@ describe("RantDialog", () => { it("提交 payload {message, agent}(选项目)", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(wrapper()); + render(wrapper()); fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "侧栏太宽了" } }); - fireEvent.change(screen.getByTestId("rant-project"), { target: { value: "emrg" } }); + fireEvent.change(screen.getByTestId("rant-agent"), { target: { value: "emrg" } }); fireEvent.click(screen.getByTestId("rant-submit")); await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); expect(onSubmit.mock.calls[0][0]).toEqual({ message: "侧栏太宽了", agent: "emrg" }); }); - it("项目选填:留空提交 project=''", async () => { + it("项目选填:留空提交 agent=''", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(wrapper()); + render(wrapper()); fireEvent.change(screen.getByTestId("rant-message"), { target: { value: "hello" } }); fireEvent.click(screen.getByTestId("rant-submit")); await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); diff --git a/emrg/gui/renderer/src/components/RantDialog.tsx b/emrg/gui/renderer/src/components/RantDialog.tsx index 425bad64b..9671b7b92 100644 --- a/emrg/gui/renderer/src/components/RantDialog.tsx +++ b/emrg/gui/renderer/src/components/RantDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useI18n } from "../lib/i18n"; -import type { ProjectRec } from "../lib/workspaceView"; +import type { AgentRec } from "../lib/workspaceView"; import { Dialog } from "./Dialog"; /** @@ -8,19 +8,19 @@ import { Dialog } from "./Dialog"; * 镜像 vanilla `openRantForm()` / `submitRantForm()`(renderer/js/dialogs.js): * * - 打开即清空消息、聚焦输入; - * - 项目下拉 = 已注册项目(选填,value=name||path,与 daemon rant 协议 project 字段一致); + * - 项目下拉 = 已注册项目(选填,value=name||path,与 daemon rant 协议 agent 字段一致); * - 消息必填(空 → 内联错误;vanilla 用 showConfirm 提示); - * - 提交 → onSubmit({message, project})(调用方接线 sendRant + 刷新列表 + 反馈)。 + * - 提交 → onSubmit({message, agent})(调用方接线 sendRant + 刷新列表 + 反馈)。 */ export interface RantDialogProps { open: boolean; /** 项目下拉选项(仅已注册项目;选填) */ - projects?: ProjectRec[]; - onSubmit?: (payload: { message: string; project: string }) => void | Promise; + agents?: AgentRec[]; + onSubmit?: (payload: { message: string; agent: string }) => void | Promise; onDismiss?: () => void; } -export function RantDialog({ open, projects = [], onSubmit, onDismiss }: RantDialogProps) { +export function RantDialog({ open, agents = [], onSubmit, onDismiss }: RantDialogProps) { const { t } = useI18n(); const [err, setErr] = useState(null); const msgRef = useRef(null); @@ -41,7 +41,7 @@ export function RantDialog({ open, projects = [], onSubmit, onDismiss }: RantDia return; } try { - await onSubmit?.({ message: msg, project: projectRef.current?.value ?? "" }); + await onSubmit?.({ message: msg, agent: projectRef.current?.value ?? "" }); } finally { onDismiss?.(); } @@ -76,10 +76,10 @@ export function RantDialog({ open, projects = [], onSubmit, onDismiss }: RantDia