Skip to content

Commit 15d8f4d

Browse files
fengzhi09s39-dev
andauthored
feat(webui): transcripts carry the turn's assistant message id (#124)
The runtime selects a turn diff by id and degrades to "the latest turn" without a selector, so a card that cannot name its turn can only ever show the most recent turn's numbers. The webui transcript had no turn coordinate at all. The id was already on the wire: every ACP chunk carries a messageId and the client collected them into a set it never read. This writes the last one of a turn — the last agent_message_chunk, not any chunk, since a turn streams one id for its thinking and another for its answer — and synthesises the same mark when a transcript is rebuilt from the runtime message table. Nothing consumes it yet; the endpoints that will land next read it. Co-authored-by: s39-dev <s39-dev@local>
1 parent d8dea16 commit 15d8f4d

11 files changed

Lines changed: 755 additions & 24 deletions

File tree

‎packages/webui/acp.mjs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export class McodeAcpClient extends EventEmitter {
372372
// hold. The old turn-long concatenation leaked into the [send]
373373
// result log, the ● finalize rewrite, the no-usage token
374374
// estimate and the empty-turn note.
375-
const result = { thinking: '', answer: '', messageIds: new Set(), stopReason: null, lastChunkKind: null }
375+
const result = { thinking: '', answer: '', messageIds: new Set(), lastAssistantMessageId: null, stopReason: null, lastChunkKind: null }
376376
const onUpdate = (u) => {
377377
if (u.sessionUpdate === 'agent_thought_chunk' && u.content?.type === 'text') {
378378
if (result.lastChunkKind !== 'thought') result.thinking = ''
@@ -385,6 +385,14 @@ export class McodeAcpClient extends EventEmitter {
385385
result.answer += u.content.text
386386
result.lastChunkKind = 'message'
387387
if (u.messageId) result.messageIds.add(u.messageId)
388+
// webui-parity 83 (turn coordinate): the engine persists a turn's
389+
// diff record under the msg_id of the LAST assistant *message* of
390+
// that turn (turn-outcome.ts#readAssistantMessageId reads only
391+
// AgentMessage / AgentMessageChunk — never a thought). A turn
392+
// carries more than one messageId on the wire (one per message
393+
// segment), so "any chunk" would select the wrong record; this
394+
// field keeps the last one and nothing else consumes it yet.
395+
if (u.messageId) result.lastAssistantMessageId = u.messageId
388396
try { onChunk?.({ kind: 'message', text: u.content.text }) } catch {}
389397
} else if (u.sessionUpdate === 'tool_call') {
390398
// session-isolation/07 (acceptance alignment): ONLY

‎packages/webui/server/lib/mcode-acp.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,21 @@ function streamAcpPrompt(
806806
chatTarget.push(`§§ processed_duration=${Math.round(r.durationMs)}ms`);
807807
}
808808
}
809+
// webui-parity 83 (turn coordinate): the same finalize writes the
810+
// engine-side turn selector into the transcript as `§§ turn_msg=<id>`.
811+
// `r.assistantMessageId` is the msg_id of the turn's LAST assistant
812+
// message chunk (acp.mjs#prompt), which is the exact value the engine
813+
// persisted as `local_runtime_turn_diffs.assistant_message_id` — so the
814+
// turn-diff endpoint can select THIS turn's record without a sequence
815+
// translation. It is written only when the prompt settled normally; a
816+
// turn the engine never finalized has no diff record, so a marker there
817+
// would only invite a lookup that returns nothing.
818+
if (typeof r.assistantMessageId === "string" && r.assistantMessageId) {
819+
const msgTarget = r && typeof r.chatArray === "function" ? r.chatArray() : cs.chat;
820+
if (Array.isArray(msgTarget)) {
821+
msgTarget.push(`§§ turn_msg=${r.assistantMessageId}`);
822+
}
823+
}
809824
clearActiveChild(cid);
810825
cs.running = {
811826
active: false,
@@ -1234,6 +1249,10 @@ function streamAcpPrompt(
12341249
r.answer = result.answer || r.answer;
12351250
r.thinking = result.thinking || r.thinking;
12361251
r.stopReason = result.stopReason;
1252+
// webui-parity 83: the turn's engine-side coordinate, read off the
1253+
// LAST assistant message chunk. `finalize()` writes it as
1254+
// `§§ turn_msg=<id>`; see the marker branch there.
1255+
r.assistantMessageId = result.lastAssistantMessageId || r.assistantMessageId;
12371256
// v0.5.bx: 捕获 mcode 返的 usage(totalTokens/inputTokens/outputTokens/thoughtTokens)
12381257
if (result.usage) r.usage = result.usage;
12391258
r.status = "succeeded";

‎packages/webui/server/lib/transcript.js‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,18 @@ export const LEGACY_TRANSCRIPT_PROBES = [
6767
// why export's enrichment was silently dead — this probe revives reads for
6868
// the switch path. NOT part of the default set: export.js must not change
6969
// behavior, so only callers that opt in (switch backfill) append it.
70+
//
71+
// webui-parity 83: the probe also selects `turn_id` and `msg_id`. Those two
72+
// columns are the engine's own turn coordinate — the last assistant row of a
73+
// turn carries exactly the `assistant_message_id` the turn's diff record was
74+
// persisted under — so the mapper can synthesise the same `§§ turn_msg=<id>`
75+
// marker the live path writes, and an existing session gains the coordinate
76+
// on the next switch without the engine being asked anything.
7077
export const V2_DATA_JSON_PROBES = [
7178
{
7279
table: "local_runtime_message_rows",
7380
sql:
74-
"SELECT role, data_json FROM local_runtime_message_rows WHERE session_id = ? ORDER BY created_at_ms ASC, rowid ASC",
81+
"SELECT role, data_json, turn_id, msg_id FROM local_runtime_message_rows WHERE session_id = ? ORDER BY created_at_ms ASC, rowid ASC",
7582
kind: "v2-data-json",
7683
mapRow: _mapV2DataJsonRow,
7784
},
@@ -174,6 +181,14 @@ function _mapV2DataJsonRow(r) {
174181
const m = { role, content };
175182
if (thinking) m.thinking = thinking;
176183
if (tool_calls) m.tool_calls = tool_calls;
184+
// webui-parity 83: the engine's own turn coordinate. The COLUMN is
185+
// authoritative — `data_json` carries its own copies of both fields, and a
186+
// row whose column and payload disagree (partial write, schema drift) must
187+
// not fabricate a selector. Absent (legacy table, or a row the engine wrote
188+
// before these columns existed) simply means "no coordinate for this turn",
189+
// and the transcript degrades to the marker-free card.
190+
if (typeof r.turn_id === "string" && r.turn_id) m.turnId = r.turn_id;
191+
if (typeof r.msg_id === "string" && r.msg_id) m.msgId = r.msg_id;
177192
return m;
178193
}
179194

@@ -379,11 +394,36 @@ export function messagesToChatLines(messages, opts = {}) {
379394
const src = Array.isArray(messages) ? messages : [];
380395
const raw = [];
381396
let skipped = 0;
397+
// webui-parity 83 (turn coordinate). Every v2 row carries the engine's own
398+
// `turn_id`, and the LAST assistant row of a turn carries the exact msg_id
399+
// that turn's diff record was persisted under (turn-outcome.ts reads the
400+
// last agent *message* response; the runtime writes one message row per
401+
// message). Emitting `§§ turn_msg=<id>` at the end of each turn group gives
402+
// an existing session the same coordinate the live path writes, so one
403+
// decoder serves both. Rows without a `turn_id` (legacy probes) contribute
404+
// no marker at all — the card degrades instead of inventing one.
405+
let groupTurnId = null;
406+
let groupTurnMsgId = null;
407+
const flushTurnGroup = () => {
408+
if (groupTurnId !== null && groupTurnMsgId) raw.push(`§§ turn_msg=${groupTurnMsgId}`);
409+
groupTurnId = null;
410+
groupTurnMsgId = null;
411+
};
382412
for (const m of src) {
413+
const turnId = m && typeof m.turnId === "string" && m.turnId ? m.turnId : null;
414+
if (turnId !== groupTurnId) {
415+
flushTurnGroup();
416+
groupTurnId = turnId;
417+
}
383418
const lines = _messageToLines(m);
384419
if (lines.length === 0) skipped++;
385420
raw.push(...lines);
421+
const role = m && typeof m.role === "string" ? m.role.toLowerCase() : "";
422+
if (role === "assistant" && typeof m.msgId === "string" && m.msgId) {
423+
groupTurnMsgId = m.msgId;
424+
}
386425
}
426+
flushTurnGroup();
387427
// Cap 1 — line count: keep the TAIL (recent history is what the user
388428
// switched TO see).
389429
let lines = raw.length > maxLines ? raw.slice(raw.length - maxLines) : raw.slice();

‎packages/webui/server/routes/export.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ function _parseChatLines(lines) {
102102
const line = raw.replace(/\s+$/, "");
103103
if (!line) continue;
104104

105+
// Server-written per-turn metadata, not conversation text.
106+
//
107+
// `§§ processed_duration=Nms` (prompt finalise) and `§§ turn_msg=<id>`
108+
// (webui-parity 83, the engine's turn selector) are one wire convention:
109+
// a marker line the webapp's `decodeTranscript` consumes and never
110+
// renders. This parser is the SECOND reader of that transcript, so it has
111+
// to skip them too — otherwise an exported assistant message ends with a
112+
// raw `§§ …` line glued onto its text. `§§` is outside the glyph
113+
// vocabulary, so no message shape this grammar can produce starts with
114+
// one, and the skip cannot swallow conversation.
115+
if (/^§§\s/.test(line)) continue;
116+
105117
// Plan block
106118
if (/^Plan\s*[::]\s*.+/i.test(line.trim())) {
107119
flush();
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// webui/test/lib/acp-turn-message-id.test.js
2+
// webui-parity 83 — which messageId the transport keeps for a turn.
3+
//
4+
// The engine persists a turn's file-change record under the msg_id of that
5+
// turn's LAST assistant MESSAGE
6+
// (`local-runtime-v2/.../turn-outcome.ts#readAssistantMessageId` reads
7+
// AgentMessage / AgentMessageChunk and never a thought). A turn carries
8+
// more than one id on the wire — one per message segment, plus one per
9+
// thought segment — so "any id" selects the wrong record.
10+
//
11+
// The rule is pinned here against the real `McodeAcpClient#prompt`, driven
12+
// by a fake `request` that replays `session/update` frames. No child process
13+
// is spawned: the client is constructed but never `start()`ed, and only
14+
// `on` / `off` / `request` are exercised.
15+
16+
import { test, describe } from "node:test";
17+
import assert from "node:assert/strict";
18+
import { pathToFileURL } from "node:url";
19+
import { join } from "node:path";
20+
21+
const absPath = (rel) =>
22+
pathToFileURL(join(import.meta.dirname, "..", "..", rel)).href;
23+
const { McodeAcpClient } = await import(absPath("acp.mjs"));
24+
25+
/**
26+
* Replay `frames` as `session/update` events, then resolve `session/prompt`.
27+
* `prompt` subscribes through `on` and unsubscribes in its own settle path,
28+
* so the fake has to dispatch through the real EventEmitter.
29+
*/
30+
function fakeClient(frames) {
31+
const client = new McodeAcpClient();
32+
client.started = false;
33+
client.request = async (method) => {
34+
if (method !== "session/prompt") throw new Error(`unexpected request ${method}`);
35+
for (const frame of frames) client.emit("sessionUpdate", frame);
36+
return { stopReason: "end_turn" };
37+
};
38+
return client;
39+
}
40+
41+
const text = (t) => ({ type: "text", text: t });
42+
const thought = (id, t) => ({ sessionUpdate: "agent_thought_chunk", content: text(t), messageId: id });
43+
const message = (id, t) => ({ sessionUpdate: "agent_message_chunk", content: text(t), messageId: id });
44+
45+
describe("prompt keeps the turn's last assistant messageId", () => {
46+
test("a simple turn yields that turn's only message id", async () => {
47+
const client = fakeClient([message("m1", "答案")]);
48+
const result = await client.prompt("mvs_x", "hi", () => {});
49+
assert.equal(result.lastAssistantMessageId, "m1");
50+
});
51+
52+
test("a turn with several message segments keeps the LAST one", async () => {
53+
// The turn the real engine runs: a first message, a tool call, then a
54+
// second message. The record is filed under the second one.
55+
const client = fakeClient([
56+
message("m1", "我先看看"),
57+
{ sessionUpdate: "tool_call", toolCallId: "t1", title: "bash" },
58+
message("m2", "改好了"),
59+
]);
60+
const result = await client.prompt("mvs_x", "hi", () => {});
61+
assert.equal(result.lastAssistantMessageId, "m2");
62+
});
63+
64+
test("a thought id never wins over the message id", async () => {
65+
// The engine's reader ignores thoughts entirely. Taking the thought's id
66+
// would send the endpoint a selector that matches no diff record — an
67+
// empty card on a turn that really did change files.
68+
const client = fakeClient([
69+
thought("t1", "先想一下"),
70+
message("m1", "动手"),
71+
thought("t2", "再想想"),
72+
]);
73+
const result = await client.prompt("mvs_x", "hi", () => {});
74+
assert.equal(result.lastAssistantMessageId, "m1");
75+
});
76+
77+
test("a turn with no message chunk yields no id at all", async () => {
78+
// A thinking-only turn has no assistant message, so there is no selector.
79+
// Emitting the thought's id here would be the "any chunk" bug wearing a
80+
// different hat.
81+
const client = fakeClient([thought("t1", "只有思考")]);
82+
const result = await client.prompt("mvs_x", "hi", () => {});
83+
assert.equal(result.lastAssistantMessageId, null);
84+
});
85+
86+
test("a chunk without a messageId does not clear the one already seen", async () => {
87+
const client = fakeClient([message("m1", "有 id"), message(undefined, "没有 id")]);
88+
const result = await client.prompt("mvs_x", "hi", () => {});
89+
assert.equal(result.lastAssistantMessageId, "m1");
90+
});
91+
92+
test("the id is per-prompt, not accumulated on the client", async () => {
93+
// Two turns on one client: the second must not inherit the first turn's
94+
// id, or every card after the first would query the wrong turn.
95+
const queue = [[message("first", "第一轮")], [message("second", "第二轮")]];
96+
const client = fakeClient([]);
97+
client.request = async (method) => {
98+
if (method !== "session/prompt") throw new Error("unexpected");
99+
for (const f of queue.shift() ?? []) client.emit("sessionUpdate", f);
100+
return { stopReason: "end_turn" };
101+
};
102+
const one = await client.prompt("mvs_x", "one", () => {});
103+
assert.equal(one.lastAssistantMessageId, "first");
104+
const two = await client.prompt("mvs_x", "two", () => {});
105+
assert.equal(two.lastAssistantMessageId, "second");
106+
});
107+
});

0 commit comments

Comments
 (0)