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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (161: 98 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (710: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 26 TranscriptView + 22 history + 23 historyReplay + 38 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog)
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (714: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 38 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog)
- CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh <branch>`, never an empty commit
- Merge gates, run before merging — each 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
7 changes: 7 additions & 0 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ 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,
# 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 ""),
))
return cards

Expand Down Expand Up @@ -1534,6 +1538,9 @@ async def _reconnect():
status="running",
tool_call_id=ts.tool_call_id,
arguments=ts.arguments,
# Printed as handed over: which call of its batch this is was
# decided in the daemon (rant 2026-10-20T12:22:21).
call_label=ts.call_label,
)
chat.add(card)
_last_center = f"running {ts.tool_name}..."
Expand Down
10 changes: 8 additions & 2 deletions emrg/client/python_tui/widgets/tool_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ class ToolCard(Widget):
output: str = ""
tool_call_id: str = ""
arguments: dict = field(default_factory=dict)
#: Which call of its batch this is — `"(2/3) "`, trailing space included, or `""`
#: for a single call. **Printed, never computed here**: the daemon decides what a
#: batch is (`daemon._tool_call_labels`) so the two clients cannot disagree
#: (rant 2026-10-20T12:22:21). Empty for every frame and record that predates it,
#: which is why a turn from before this change renders exactly as it used to.
call_label: str = ""
elapsed: float = 0.0
_dirty: bool = True

Expand Down Expand Up @@ -102,8 +108,8 @@ def render(self, ctx: RenderContext) -> list[Line]:
# nothing left to expand into — the detail lives in the selector, and a
# chevron that promised an interaction the TUI does not have would be the
# same defect the removed Tab handler was.
header = (f"{self.icon} {self.name}: {self.command}"
if self.command else f"{self.icon} {self.name}")
header = (f"{self.icon} {self.call_label}{self.name}: {self.command}"
if self.command else f"{self.icon} {self.call_label}{self.name}")
lines = [Line(
spans=[Span(text=header, style=style_str)],
style=ctx.style,
Expand Down
31 changes: 31 additions & 0 deletions emrg/gui/renderer/src/components/TranscriptView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,37 @@ describe("TranscriptView", () => {
expect(full.textContent!.endsWith("…")).toBe(false);
});

it("同批第几个显示在工具名之前,daemon 给什么就打印什么(rant 2026-10-20T12:22:21)", () => {
const store = createTranscriptStore({ t: (k) => k });
store.handleToolStart(
{ tool_call_id: "t1", tool_name: "bash", request_id: "r1", intent: "check config", call_label: "(2/3) " },
"s1",
);
const { container } = setup(store, "s1");
const row = container.querySelector(".tool-row")!;
const label = row.querySelector(".tool-call-label");
// 逐字比较:toHaveTextContent 会归一空白,正好抹掉那个尾随空格——而那个空格是
// 协议的一部分(label 自带尾随空格,客户端不再自己拼)。
expect(label!.textContent).toBe("(2/3) ");
// 位置:label 紧贴在工具名之前,不是名字后面、也不是 intent 后面
const name = row.querySelector(".tool-name")!;
expect(label!.nextElementSibling).toBe(name);
expect(name).toHaveTextContent("bash");
expect(row.textContent!.indexOf("(2/3) ")).toBeLessThan(row.textContent!.indexOf("bash"));
});

it("没有 call_label 时行与改动前逐字相同:不出现 (1/1)、不出现空括号", () => {
const store = createTranscriptStore({ t: (k) => k });
store.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1", intent: "check config" }, "s1");
const { container } = setup(store, "s1");
const row = container.querySelector(".tool-row")!;
// 缺字段 = 连节点都没有(空 span 也会让「逐字相同」不成立)
expect(row.querySelector(".tool-call-label")).toBeNull();
expect(row.querySelector(".tool-name")).toHaveTextContent(/^bash$/);
expect(row.textContent).not.toContain("(1/1)");
expect(row.textContent).not.toContain("(");
});

it("失败工具行:failed label(tool.failText)且无耗时", () => {
const store = createTranscriptStore({ t: (k) => k });
store.handleToolStart({ tool_call_id: "t1", tool_name: "bash", request_id: "r1" }, "s1");
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/TranscriptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ function ToolRowView({
>
{row.status === "running" ? <span className="tool-spinner" /> : null}
{ok ? <span className="tool-check">✓ </span> : null}
{row.callLabel ? <span className="tool-call-label">{row.callLabel}</span> : null}
<span className="tool-name">{row.toolName}</span>
<span className="tool-label">{label}</span>
{row.intent ? <span className="tool-intent">{row.intent}</span> : null}
Expand Down
54 changes: 54 additions & 0 deletions emrg/gui/renderer/src/lib/historyReplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,60 @@ describe("replayHistoryRecords is equivalent to the live stream", () => {
expect(group.group.rows.map((r) => r.intent)).toEqual(["list the files", "read README"]);
});

/** 所有工具行的 callLabel,按出现顺序——不假设是否已并进 group(取决于结果是否先到)。 */
function rowsWithLabel(replay: ReturnType<typeof createTranscriptStore>): Array<string | undefined> {
const out: Array<string | undefined> = [];
for (const entry of replay.getEntries(SID)) {
if (entry.kind === "tool-row") out.push((entry as { row: { callLabel?: string } }).row.callLabel);
if (entry.kind === "tool-group") {
for (const row of (entry as { group: { rows: Array<{ callLabel?: string }> } }).group.rows) {
out.push(row.callLabel);
}
}
}
return out;
}

it("carries the daemon's batch label out of tool_calls[].call_label (rant 2026-10-20T12:22:21)", () => {
// daemon 的历史投影在每一项 tool_calls 上算好 call_label;回放只透传,不自己数。
const records: HistoryRecord[] = [
{ record_index: 0, kind: "message", role: "user", content: "q" },
{
record_index: 1,
kind: "message",
role: "assistant",
content: "",
tool_calls: [
{ id: "c1", function: { name: "bash", arguments: '{"command":"ls"}' }, call_label: "(1/2) " },
{ id: "c2", function: { name: "read", arguments: '{"path":"a"}' }, call_label: "(2/2) " },
],
},
];
const replay = createTranscriptStore();
replayHistoryRecords(records, SID, replay);
expect(rowsWithLabel(replay)).toEqual(["(1/2) ", "(2/2) "]);
});

it("leaves callLabel undefined when the record carries none — the client never counts", () => {
const records: HistoryRecord[] = [
{ record_index: 0, kind: "message", role: "user", content: "q" },
{
record_index: 1,
kind: "message",
role: "assistant",
content: "",
tool_calls: [
{ id: "c1", function: { name: "bash", arguments: '{"command":"ls"}' } },
{ id: "c2", function: { name: "read", arguments: '{"path":"a"}' } },
],
},
];
const replay = createTranscriptStore();
replayHistoryRecords(records, SID, replay);
// 两条调用、没有字段 ⇒ 两条都不许冒出 (1/2):客户端不自己算
expect(rowsWithLabel(replay)).toEqual([undefined, undefined]);
});

it("does not invent an empty assistant bubble for a tool-only round", () => {
const live = createTranscriptStore();
feedLive(live, SID, { user: "q", rounds: [{ text: "", tools: [{ id: "c1", name: "bash", intent: "go", output: "ok" }] }, { text: "done", tools: [] }] });
Expand Down
17 changes: 15 additions & 2 deletions emrg/gui/renderer/src/lib/historyReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ export interface HistoryMessageRecord {
role?: string;
content?: string;
timestamp?: string;
tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: string } }>;
/**
* `call_label` 不在落盘记录里:daemon 在 **`list_history` 读取时**把它算进每一项的副本
* (`daemon._tool_call_labels`),所以这个字段只在「已被投影过的记录」上出现。类型放宽
* 是因为这就是回放看到的东西,不是要写进磁盘(rant 2026-10-20T12:22:21)。
*/
tool_calls?: Array<{
id?: string;
function?: { name?: string; arguments?: string };
call_label?: string;
}>;
}

/** 落盘记录里的一条工具结果(daemon `include_records` 的 `kind: "tool_result"`)。 */
Expand Down Expand Up @@ -75,7 +84,7 @@ export interface ReplaySink {
sid?: string | null,
): void;
handleToolStart(
data: { request_id?: string; tool_call_id: string; tool_name: string; intent?: string; arguments?: unknown },
data: { request_id?: string; tool_call_id: string; tool_name: string; intent?: string; arguments?: unknown; call_label?: string },
sid?: string | null,
): void;
handleToolEnd(
Expand Down Expand Up @@ -212,6 +221,10 @@ export function replayHistoryRecords(
// 于是「打开旧会话」的折叠行与展开详情与实时不一致。归一成实时那一形状后透传
// (截断/非法 JSON 由 toolCallArguments 与 formatToolArguments 兜底)。
arguments: toolCallArguments(tc?.function?.arguments),
// daemon 的历史投影在每一项 tool_calls 上算好了 `call_label`(读时算的,
// 旧记录也有)——回放只是透传,走的是同一个 handleToolStart
// (rant 2026-10-20T12:22:21)。
call_label: tc?.call_label,
},
sid,
);
Expand Down
11 changes: 11 additions & 0 deletions emrg/gui/renderer/src/lib/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface ToolRow {
toolName: string;
status: "running" | "done" | "failed";
intent?: string;
/**
* 这条调用是同一批(一条 assistant 响应)里的第几个 —— `"(2/3) "`,自带尾随空格;
* 只有一条调用时是 `""`。**由 daemon 算好,这里只负责打印**:客户端自己数就会是同一个
* 问题的第二个答案,而错的 `(2/3)` 和对的看起来一模一样(rant 2026-10-20T12:22:21)。
* 旧帧与旧记录没有这个字段 ⇒ `undefined` ⇒ 渲染结果与改动前逐字相同。
*/
callLabel?: string;
/**
* 工具入参(rant 2026-09-30T09:17:54 → #1787)。实时是 daemon 解析好的对象;回放那一路
* 记录里存的是 `function.arguments` 的 **JSON 字符串**,但 `historyReplay.toolCallArguments`
Expand Down Expand Up @@ -161,6 +168,8 @@ export interface ToolStartData {
intent?: string;
/** daemon 的 `tool_start` 帧本就带 `arguments`(解析好的对象)——此前在这里被丢掉。 */
arguments?: unknown;
/** 同批第几个,daemon 算好的成品字符串(`"(2/3) "` / `""`)——rant 2026-10-20T12:22:21。 */
call_label?: string;
}
export interface ToolEndData {
tool_call_id: string;
Expand Down Expand Up @@ -469,6 +478,8 @@ export function createTranscriptStore(opts: { t?: TranslateFn } = {}): Transcrip
status: "running",
intent: data.intent,
arguments: data.arguments,
// daemon 给什么就存什么,不数、不拼(rant 2026-10-20T12:22:21)。
callLabel: data.call_label,
outputExpanded: false,
fullExpanded: false,
};
Expand Down
8 changes: 8 additions & 0 deletions emrg/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ class ToolStart:
tool_call_id: str = ""
arguments: dict = field(default_factory=dict)
intent: str = ""
#: Which call of its batch this is, as a finished string — `"(2/3) "` — or `""`
#: when the response issued a single call. Composed by the daemon
#: (`daemon._tool_call_labels`) and printed verbatim by every client: a client
#: that counted would be a second answer to a question the daemon already
#: answered (rant 2026-10-20T12:22:21). Absent on an older frame ⇒ `""`, which
#: is the same thing a single call shows.
call_label: str = ""

@classmethod
def from_dict(cls, d: dict) -> ToolStart:
Expand All @@ -97,6 +104,7 @@ def from_dict(cls, d: dict) -> ToolStart:
tool_call_id=d.get("tool_call_id", ""),
arguments=d.get("arguments", {}),
intent=d.get("intent", ""),
call_label=d.get("call_label", ""),
)


Expand Down
46 changes: 44 additions & 2 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,33 @@ def _abort_run_sentence(record: dict, round_num: int) -> str:
)


def _tool_call_labels(tool_calls) -> list[str]:
"""The label each call of one assistant response carries, ready to print.

**This is the only place the rule lives.** A client that counted the calls
itself would be a second answer to the same question, and the two would drift
the first time either side changed — invisibly, because a wrong `(2/3)` looks
exactly like a right one. So the daemon decides here, hands out a finished
string, and every renderer prints what it was given.

A batch is **one assistant response's `tool_calls` array**: `y` is its length
and `x` counts from 1. The string carries its own trailing space, so a row is
`f"{icon} {label}{name}"` with no branch and no arithmetic on the client side.

A response that issued a single call gets `""` — `(1/1)` would be noise on
every ordinary turn, where the absence of a label already means "one call".

Calls whose permission was refused, whose execution failed, or whose tool name
was unknown are all still calls of this response: they are counted, because the
host reading the transcript wants to see the batch the model emitted, not the
subset that happened to succeed.
"""
total = len(tool_calls or [])
if total < 2:
return [""] * total
return [f"({index}/{total}) " for index in range(1, total + 1)]


def build_shell_tool(
sandbox_config: Optional[SandboxConfig] = None,
platform_name: str | None = None,
Expand Down Expand Up @@ -3431,7 +3458,17 @@ async def _process_message(
}
tool_calls = r.get("tool_calls")
if tool_calls:
item["tool_calls"] = tool_calls
# Copies carrying the batch label, never a mutation of the
# record: the label is computed here, at read time, so a
# session written before this existed replays with it too
# (rant 2026-10-20T12:22:21). A disk write would have made
# the answer a property of when the session was recorded.
labels = _tool_call_labels(tool_calls)
item["tool_calls"] = [
{**call, "call_label": labels[index]}
if isinstance(call, dict) else call
for index, call in enumerate(tool_calls)
]
history_messages.append(item)
elif rtype == "summary":
# One per compaction (#2351): the live stream announced it
Expand Down Expand Up @@ -4805,7 +4842,11 @@ async def _run_tool_loop(

# Execute each tool. tool_calls are already embedded in the
# assistant message above, so we only persist tool_results here.
for tc in openai_tool_calls:
# One batch, one computation (rant 2026-10-20T12:22:21): the label for
# every call of this response is decided before the loop, so the frame
# carries a finished string and no client has to count anything.
call_labels = _tool_call_labels(openai_tool_calls)
for call_index, tc in enumerate(openai_tool_calls):
tc_id = tc["id"]
tc_name = tc["function"]["name"]
tc_args_str = tc["function"]["arguments"]
Expand All @@ -4830,6 +4871,7 @@ async def _run_tool_loop(
"tool_call_id": tc_id,
"arguments": args,
"intent": args.get("intent") or "",
"call_label": call_labels[call_index],
})

# The pieces of a call the model must not choose (D1).
Expand Down
Loading
Loading