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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ EMRG is a self-evolving AI agent architecture experiment: a **micro-kernel** —

**The server is the living core; a client is only the interface.** Task understanding, tool scheduling, capability growth and self-evolution live in the server; one server serves many clients and outlives any of them.

- `emrg/` — core: `__main__.py` (CLI), `protocol.py`, `config.py` (`~/.emrg/config.toml`), `connect.py` (WebSocket over loopback, token auth)
- `emrg/` — core: `__main__.py` (CLI), `protocol.py`, `config.py`, `connect.py` (WebSocket over loopback, token auth)
- `emrg/server/` — the daemon: `daemon.py` (messages, the background thread running evolution, the tool loop), `llm.py`, `tool_types.py`, task templates
- `emrg/tools/` — bash, read, write, edit, glob, grep, base + registry
- `emrg/skills/` — skills from `.emrg/skills/` (progressive disclosure) + the installable-skill catalog
Expand Down Expand Up @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py

- Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (156: 95 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (666: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 24 transcript + 23 TranscriptView + 22 history + 18 historyReplay + 38 composer + 65 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 45 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog)
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (684: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 20 historyReplay + 38 composer + 65 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 50 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog)
- CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh <branch>`, never an empty commit
- Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3`
- Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py <N>` (did the merge land a tree its reviews named)
Expand Down
8 changes: 8 additions & 0 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,14 @@ class DaemonClient {
this._emit(frame.type, frame);
return;
}
if (frame.type === "user_message") {
// 新消息落盘的回执(rant 2026-10-17T10:03:08):daemon 在写用户记录的同一处
// `_broadcast` 它,带 `request_id` 与 `timestamp` —— 那一刻只有 daemon 知道,
// 渲染层据此给乐观画出来的那一行补上时间。此前本层没有分支,它落进下面的兜底
// 只记一行日志,于是 GUI 的用户消息永远没有时刻(TUI 一直在用这个帧)。
this._emit(frame.type, frame);
return;
}
if (frame.type === "task_queued" || frame.type === "steer_committed") {
// The same seam as the turn frames above, for two more types (#2189). The
// daemon `_broadcast`s both to every connection subscribed to the session
Expand Down
12 changes: 12 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@
color: var(--accent);
animation: cursor-pulse 1s ease-in-out infinite;
}
/* 消息行上的时钟(rant 2026-10-17T10:03:08):dim、等宽数字,不抢正文。
元素只在 daemon 真的给了时刻时才存在(`formatMessageTime` 反问可读性),
所以这里没有「空值形态」需要兜。 */
.msg-time {
display: block;
margin-top: 2px;
font-size: var(--fs-aux);
color: var(--text-3);
font-variant-numeric: tabular-nums;
user-select: none;
}

.msg.system {
text-align: center;
font-size: var(--fs-secondary);
Expand Down
9 changes: 6 additions & 3 deletions emrg/gui/renderer/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,16 @@ export function Composer({
// 回合结束未注入则 queued_requeue 以原 requestId 重发。busy 时记录待重发条目。
const wasBusy = busy;
setInternalBusy(true);
store.addUserMessage(value, sid);
editor.commands.clearContent();
// B3:消息已发送 → 清除草稿(tiptap 内容即草稿,发送即清)
// G143:send 前预生成 requestId——消除 IPC 往返竞态窗口。(G143 原本还用它给消息打
// 「自有流」标;那是客户端自持的“谁的消息”判定,已按 rant 2026-09-29T15:52:49 要求 3
// 移除:daemon 是唯一状态源,客户端只是渲染器。)
// 铸在这里、并在插入消息行时就交给它:这一行要靠这个 id 认领 daemon 随后送来的
// `user_message` / `steer_committed` 帧里的时刻(rant 2026-10-17T10:03:08)——
// 客户端自己不知道这条消息是什么时候被记下的,也不许编一个。
const requestId = genRequestId();
store.addUserMessage(value, sid, { requestId });
editor.commands.clearContent();
// B3:消息已发送 → 清除草稿(tiptap 内容即草稿,发送即清)
if (wasBusy) {
queueSend(queuedRef.current, sid, { requestId, text: value, sandbox: tier, images });
}
Expand Down
45 changes: 45 additions & 0 deletions emrg/gui/renderer/src/components/TranscriptView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,3 +541,48 @@ describe("TranscriptView", () => {
expect(viewport.scrollTop).toBe(0);
});
});

/**
* 消息行上的时刻(rant 2026-10-17T10:03:08 → issue #2340)。断言读的是**渲染产物**:
* 类型上有没有字段、store 里有没有值,都不算 —— 宿主看的是屏幕上那一行。
*/
describe("消息时刻", () => {
it("用户行与助手行各显示 daemon 给的那个 HH:MM", async () => {
const store = createTranscriptStore({ t: (k) => k });
store.addUserMessage("hi", "s1", {
requestId: "req-1",
timestamp: "2026-10-17T10:27:55.123456+08:00",
});
store.handleDelta([{ request_id: "req-1", content: "hello" }], "s1");
store.handleDone({ request_id: "req-1", timestamp: "2026-10-17T10:28:09+08:00" }, "s1");
const { container } = setup(store, "s1");

const times = Array.from(container.querySelectorAll(".msg-time")).map((el) => el.textContent);
expect(times).toEqual(["10:27", "10:28"]);
// 完整日期挂在悬停上,行上只留时钟
expect(container.querySelector(".msg.user .msg-time")!.getAttribute("title")).toBe(
"2026-10-17 10:27",
);
// 助手正文照旧渲染(时间元素没把消息顶掉)
await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
});

it("没有时刻的行不渲染任何时间元素:不是空括号,不是 Invalid Date,不是 NaN", () => {
const store = createTranscriptStore({ t: (k) => k });
store.addUserMessage("no clock", "s1"); // 帧还没来(或本就没有时刻的老记录)
store.handleDelta([{ request_id: "r1", content: "reply" }], "s1");
store.handleDone({ request_id: "r1", timeout: true }, "s1"); // 取消:没有回复落盘
const { container } = setup(store, "s1");

expect(container.querySelector(".msg-time")).toBeNull();
const text = container.textContent ?? "";
expect(text).not.toMatch(/Invalid|NaN|\(\)|()|\[\]/);
});

it("坏时刻同样什么都不画(记录可读性由格式器裁决,视图不做第二次猜测)", () => {
const store = createTranscriptStore({ t: (k) => k });
store.addUserMessage("bad clock", "s1", { requestId: "req-1", timestamp: "not a date" });
const { container } = setup(store, "s1");
expect(container.querySelector(".msg-time")).toBeNull();
});
});
34 changes: 33 additions & 1 deletion emrg/gui/renderer/src/components/TranscriptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../lib/transcript";
import { createMarkdownRenderer, type MarkdownRenderer, type StreamState } from "../lib/markdown";
import { canLoadOlder, scrollCompensation, shouldLoadOlder } from "../lib/history";
import { formatMessageTime, messageTimeTitle } from "../lib/utils";
import { toolPhrases } from "../lib/copywriting";
import { useI18n, type TranslateFn } from "../lib/i18n";
import { MarkdownText } from "./MarkdownText";
Expand Down Expand Up @@ -228,6 +229,7 @@ function EntryView({ entry, index, t, md, store, sid }: EntryViewProps): ReactNo
return (
<div className="msg user">
<MarkdownText text={entry.text} md={md} stripMark={false} />
<MessageTime stamp={entry.timestamp} />
</div>
);
case "system":
Expand All @@ -241,12 +243,38 @@ function EntryView({ entry, index, t, md, store, sid }: EntryViewProps): ReactNo
}
}

/**
* 消息行上的时刻(rant 2026-10-17T10:03:08):`HH:MM`,值只来自 daemon。
*
* 缺时刻/坏时刻渲染成**没有这个元素**,而不是空括号、`Invalid Date` 或 `NaN` ——
* 老记录本就没有时间,而“缺陷只在渲染里看得见”正是空串要避免的。完整日期在 `title` 上,
* 行上只留时钟(与 TUI 同口径)。
*/
function MessageTime({ stamp }: { stamp?: string }) {
const clock = formatMessageTime(stamp);
if (!clock) return null;
return (
<span className="msg-time" title={messageTimeTitle(stamp)}>
{clock}
</span>
);
}

/** 助手消息:每文本段一个 .msg.assistant 节点(chat.js createAssistantNode 语义) */
function AssistantView({ entry, t, md }: { entry: AssistantEntry; t: TranslateFn; md: MarkdownRenderer }) {
const last = entry.segments.length - 1;
return (
<>
{entry.segments.map((seg, i) => (
<AssistantSegmentView key={i} entry={entry} segment={seg} t={t} md={md} />
<AssistantSegmentView
key={i}
entry={entry}
segment={seg}
t={t}
md={md}
// 时刻属于这条消息,不属于每一段:一轮被工具切成多段时,只有最后一段显示它
stamp={i === last ? entry.timestamp : undefined}
/>
))}
</>
);
Expand All @@ -266,11 +294,14 @@ function AssistantSegmentView({
segment,
t,
md,
stamp,
}: {
entry: AssistantEntry;
segment: AssistantSegment;
t: TranslateFn;
md: MarkdownRenderer;
/** daemon 写下这条回复的时刻(只在最后一段传入);缺席则这一行不显示时间 */
stamp?: string;
}) {
// 块投影 host:引擎把「✦ + .md-stream」写入此节点(streamProject 返回 true 后显示;
// React 不管理其 children,避免与引擎增量 DOM 冲突)
Expand Down Expand Up @@ -335,6 +366,7 @@ function AssistantSegmentView({
</>
)}
</div>
<MessageTime stamp={stamp} />
</div>
);
}
Expand Down
65 changes: 65 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,3 +622,68 @@ describe("createDaemonBridge", () => {
});

});

/**
* 时刻从帧走到行上(rant 2026-10-17T10:03:08 → issue #2340)。daemon 早就把时刻发出来了;
* 这里守住中间这层:`done` / `user_message` / `steer_committed` 三个帧各自把 daemon 写的
* 那个字符串放到对的那一行上,且只在帧真的带了它的时候。
*/
describe("消息时刻的三个来源", () => {
const stampOf = (store: ReturnType<typeof createTranscriptStore>, sid: string, kind: "user" | "assistant") => {
const e = store.getEntries(sid).find((x) => x.kind === kind) as { timestamp?: string } | undefined;
return e?.timestamp;
};

it("done 帧的 timestamp 落到助手行上", () => {
const { bridge, transcript } = setup();
bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "hi" }] }, sid: "s1" });
bridge.handleFrame({ type: "done", data: { request_id: "r1", timestamp: "2026-10-17T10:28:09+08:00" }, sid: "s1" });
expect(stampOf(transcript, "s1", "assistant")).toBe("2026-10-17T10:28:09+08:00");
});

it("user_message 帧按 request_id 给那一行补时刻,不碰别的行", () => {
const { bridge, transcript } = setup();
transcript.addUserMessage("mine", "s1", { requestId: "req-1" });
transcript.addUserMessage("other", "s1", { requestId: "req-2" });
bridge.handleFrame({
type: "user_message",
data: { request_id: "req-1", timestamp: "2026-10-17T10:27:55+08:00" },
sid: "s1",
});
const rows = transcript.getEntries("s1") as Array<{ text: string; timestamp?: string }>;
expect(rows[0].timestamp).toBe("2026-10-17T10:27:55+08:00");
expect(rows[1].timestamp).toBeUndefined();
});

it("user_message 帧认不到那一行时什么也不做(另一个客户端发的消息)", () => {
const { bridge, transcript } = setup();
transcript.addUserMessage("mine", "s1", { requestId: "req-1" });
bridge.handleFrame({
type: "user_message",
data: { request_id: "someone-else", timestamp: "2026-10-17T10:27:55+08:00" },
sid: "s1",
});
expect(stampOf(transcript, "s1", "user")).toBeUndefined();
});

it("steer_committed 帧给排队的用户行补时刻(那一行从发送起就在屏幕上)", () => {
const { bridge, transcript } = setup();
transcript.addUserMessage("queued", "s1", { requestId: "req-9" });
bridge.handleFrame({
type: "steer_committed",
data: { request_id: "req-9", timestamp: "2026-10-17T10:31:02+08:00" },
sid: "s1",
});
expect(stampOf(transcript, "s1", "user")).toBe("2026-10-17T10:31:02+08:00");
});

it("帧里没有 timestamp 时不写任何值(缺席就是缺席,不拿空串冒充)", () => {
const { bridge, transcript } = setup();
transcript.addUserMessage("mine", "s1", { requestId: "req-1" });
bridge.handleFrame({ type: "user_message", data: { request_id: "req-1" }, sid: "s1" });
bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "hi" }] }, sid: "s1" });
bridge.handleFrame({ type: "done", data: { request_id: "r1" }, sid: "s1" });
expect(stampOf(transcript, "s1", "user")).toBeUndefined();
expect(stampOf(transcript, "s1", "assistant")).toBeUndefined();
});
});
17 changes: 17 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface QueuedData {
position?: number;
request_id?: string;
request_ids?: string[];
/**
* daemon 写下这条消息时那一刻。`steer_committed`(排队消息被注入当前回合)与
* `user_message`(新消息落盘)都带它 —— 用户行的时刻只从这里来(rant 2026-10-17T10:03:08)。
*/
timestamp?: string;
}
export interface ErrorData {
message?: string;
Expand Down Expand Up @@ -653,6 +658,15 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
}));
break;
}
case "user_message": {
// 新消息落盘的回执:这一行是客户端乐观画出来的,时刻只有 daemon 知道
// (`daemon.py` 的 `user_message` 帧带 `request_id` + `timestamp`)。
// 中间层此前把它当无人认领的帧丢掉,于是用户行的时钟永远空着 ——
// 现在按帧点名的那一行补上,别的客户端的行认不到就什么也不做。
const rid = (data as QueuedData).request_id;
if (rid) transcript.stampUserMessage(rid, (data as QueuedData).timestamp, sid);
break;
}
case "message_delta":
transcript.handleDelta(data.chunks || [data], sid);
break;
Expand Down Expand Up @@ -691,6 +705,9 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
if (idx >= 0) q.splice(idx, 1);
if (q.length === 0) queuedSends.delete(k);
}
// 排队的消息被注入时,daemon 才把它写进会话并记下那一刻 —— 这一行从发送起就在
// 屏幕上(乐观绘制),时间到这里才补得上(rant 2026-10-17T10:03:08)。
if (rid) transcript.stampUserMessage(rid, (data as QueuedData).timestamp, sid);
break;
}
case "queued_requeue": {
Expand Down
Loading
Loading