@khorsheed/dsh-client-message-tools 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.en.md +10 -10
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +9 -9
  5. package/lib/client.js +285 -112
  6. package/lib/index.js +238 -102
  7. package/lib/typert.host.js +40 -28
  8. package/lib/typert.remote-client.js +33 -21
  9. package/lib/types/client/ModelChip.js +4 -4
  10. package/lib/types/client/RestoredMessageView.js +2 -2
  11. package/lib/types/client/UserMessageView.js +11 -7
  12. package/lib/types/client/WithdrawnDividerView.d.ts +3 -2
  13. package/lib/types/client/WithdrawnDividerView.js +11 -5
  14. package/lib/types/client/chat-hook.js +1 -0
  15. package/lib/types/client/dom-hider.js +11 -4
  16. package/lib/types/client/icons-local.d.ts +23 -0
  17. package/lib/types/client/icons-local.js +11 -0
  18. package/lib/types/client/icons.d.ts +18 -19
  19. package/lib/types/client/icons.js +5 -10
  20. package/lib/types/client/index.js +6 -1
  21. package/lib/types/client/withdrawn-node.d.ts +17 -10
  22. package/lib/types/client/withdrawn-node.js +40 -22
  23. package/lib/types/index.d.ts +11 -11
  24. package/lib/types/index.js +21 -16
  25. package/lib/types/marker.d.ts +47 -15
  26. package/lib/types/marker.js +57 -30
  27. package/lib/types/restore-projection.d.ts +55 -0
  28. package/lib/types/restore-projection.js +123 -0
  29. package/lib/types/withdraw.js +67 -75
  30. package/locale/en.json +6 -0
  31. package/locale/zh.json +6 -0
  32. package/package.json +9 -28
  33. package/lib/client.js.map +0 -1
  34. package/lib/tsconfig.client.tsbuildinfo +0 -1
  35. package/lib/tsconfig.host.tsbuildinfo +0 -1
  36. package/lib/typert.remote-client.d.ts.map +0 -1
  37. package/lib/types/client/ModelChip.d.ts.map +0 -1
  38. package/lib/types/client/RestoredMessageView.d.ts.map +0 -1
  39. package/lib/types/client/UserMessageView.d.ts.map +0 -1
  40. package/lib/types/client/WithdrawnDividerView.d.ts.map +0 -1
  41. package/lib/types/client/backfill.d.ts.map +0 -1
  42. package/lib/types/client/chat-hook.d.ts.map +0 -1
  43. package/lib/types/client/dom-hider.d.ts.map +0 -1
  44. package/lib/types/client/edit-in-place.d.ts.map +0 -1
  45. package/lib/types/client/icons.d.ts.map +0 -1
  46. package/lib/types/client/index.d.ts.map +0 -1
  47. package/lib/types/client/locales.d.ts.map +0 -1
  48. package/lib/types/client/slots.d.ts.map +0 -1
  49. package/lib/types/client/withdraw-backfill.d.ts.map +0 -1
  50. package/lib/types/client/withdrawn-node.d.ts.map +0 -1
  51. package/lib/types/index.d.ts.map +0 -1
  52. package/lib/types/invariant.d.ts.map +0 -1
  53. package/lib/types/marker.d.ts.map +0 -1
  54. package/lib/types/types.d.ts.map +0 -1
  55. package/lib/types/withdraw.d.ts.map +0 -1
package/lib/index.js CHANGED
@@ -1,22 +1,30 @@
1
+ import * as sessionApi from "@deepseek-ai/dsh-session";
1
2
  import { SessionSeq } from "@deepseek-ai/dsh-session";
2
3
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
3
4
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
5
  import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from "@deepseek-ai/dsh-session/surface";
5
6
  //#region lib/types/marker.js
6
7
  /**
7
- * Shared message-tools markers: the plugin source tag stamped on withdrawal
8
- * replacement events, the `op` discriminator telling edit replacements, edit
9
- * triggers, and assistant-text restore replays apart from withdrawals and
10
- * user-message restores, the model-facing placeholder and trigger texts, and
11
- * the pure predicates both the Host service and the browser projection
12
- * consume. No new session event type is ever written — an
8
+ * Shared message-tools markers: the producer-owned source kind stamped on
9
+ * every message-tools event, the `op` discriminator telling edit
10
+ * replacements, edit triggers, and assistant-text restore replays apart from
11
+ * withdrawals and user-message restores, the model-facing placeholder and
12
+ * trigger texts, and the pure predicates both the Host service and the
13
+ * browser projection consume. No new session event type is ever written — an
13
14
  * out-of-harness event type cannot carry `ignorable: true` (Session.append
14
15
  * assigns the envelope), so a persisted unknown type would make
15
16
  * session-persistence refuse the log on reload. The events themselves are the
16
17
  * durable audit trail.
17
18
  * @module @khorsheed/dsh-client-message-tools/marker
18
19
  */
19
- /** Plugin source tag identifying every message-tools event. */
20
+ /**
21
+ * The producer-owned source kind identifying every message-tools event. It is
22
+ * also the legacy wrapper's `plugin` value: released V3 logs carry
23
+ * `{ kind: 'plugin', plugin: 'message-tools' }` (a pre-V4 host still serves
24
+ * them verbatim), and the official V3→V4 migration rewrites those to
25
+ * `kind: 'plugin:message-tools'` — {@link isMessageToolsSource} accepts all
26
+ * three durable forms.
27
+ */
20
28
  const MESSAGE_TOOLS_PLUGIN = "message-tools";
21
29
  /**
22
30
  * Model-facing placeholder left on the surface in place of the withdrawn
@@ -33,49 +41,79 @@ const WITHDRAWN_NOTICE = "(用户撤回了这条消息及其后的所有内容)"
33
41
  const EDIT_TRIGGER_NOTICE = "(用户编辑了上一条消息,请按编辑后的内容重新回答)";
34
42
  /**
35
43
  * Model-facing frame preceding a replayed assistant text in a restore: the
36
- * replay lands as a user-role message (assistant/message cannot carry a
37
- * plugin source, and the session turn/step trace forbids ad-hoc assistant
38
- * appends), so the frame keeps the role honest for the model.
44
+ * replay lands as a user-role message (assistant/message requires the model
45
+ * source, and the session turn/step trace forbids ad-hoc assistant
46
+ * appends), so the frame keeps the role honest for the model. On hosts whose
47
+ * session fold carries the S12 `user/message` projection the frame never
48
+ * reaches the model — the projection strips it and corrects the role at
49
+ * derivation time; everywhere else it is the role-honesty device.
39
50
  */
40
51
  const RESTORED_ASSISTANT_NOTICE = "(以下是先前被撤回、现随恢复放回的助手回复)";
41
- /** The plugin-tagged source of an edit replacement (op: 'edit'). */
52
+ /** The producer-owned source of a withdrawal replacement or a user-message restore replay (no op). */
53
+ function messageToolsSource() {
54
+ return Object.freeze({ kind: MESSAGE_TOOLS_PLUGIN });
55
+ }
56
+ /** The producer-owned source of an edit replacement (op: 'edit'). */
42
57
  function editReplacementSource() {
43
58
  return Object.freeze({
44
- kind: "plugin",
45
- plugin: MESSAGE_TOOLS_PLUGIN,
59
+ kind: MESSAGE_TOOLS_PLUGIN,
46
60
  op: "edit"
47
61
  });
48
62
  }
49
- /** The plugin-tagged source of an edit trigger message (op: 'edit-trigger'). */
63
+ /** The producer-owned source of an edit trigger message (op: 'edit-trigger'). */
50
64
  function editTriggerSource() {
51
65
  return Object.freeze({
52
- kind: "plugin",
53
- plugin: MESSAGE_TOOLS_PLUGIN,
66
+ kind: MESSAGE_TOOLS_PLUGIN,
54
67
  op: "edit-trigger"
55
68
  });
56
69
  }
57
- /** The plugin-tagged source of a restored assistant-text replay (op: 'restore-assistant'). */
70
+ /** The producer-owned source of a restored assistant-text replay (op: 'restore-assistant'). */
58
71
  function restoreAssistantSource() {
59
72
  return Object.freeze({
60
- kind: "plugin",
61
- plugin: MESSAGE_TOOLS_PLUGIN,
73
+ kind: MESSAGE_TOOLS_PLUGIN,
62
74
  op: "restore-assistant"
63
75
  });
64
76
  }
65
- /** Read the op discriminator off a message source (absent on legacy events). */
77
+ /** Read the op discriminator off a message source (absent on plain withdrawal/restore events). */
66
78
  function messageToolsOp(source) {
67
79
  const op = source?.op;
68
80
  return op === "edit" || op === "edit-trigger" || op === "restore-assistant" ? op : void 0;
69
81
  }
70
82
  /**
83
+ * Whether a durable source belongs to message-tools, in every form the log
84
+ * can hold: the current producer-owned kind (`message-tools`), the V3→V4
85
+ * migrated kind (`plugin:message-tools` — released V3 files rewritten once by
86
+ * the official migration), and the released V3 wrapper a pre-V4 host still
87
+ * serves verbatim (`{ kind: 'plugin', plugin: 'message-tools' }`).
88
+ * @param source - the persisted message source to test.
89
+ * @returns true when the source names this producer.
90
+ */
91
+ function isMessageToolsSource(source) {
92
+ if (typeof source !== "object" || source === null) return false;
93
+ const record = source;
94
+ return record.kind === "message-tools" || record.kind === `plugin:message-tools` || record.kind === "plugin" && record.plugin === "message-tools";
95
+ }
96
+ /**
97
+ * Strip the model-facing restore frame from a replayed assistant text for
98
+ * DISPLAY: the frame belongs to the model context (role honesty), never to
99
+ * the transcript row. Texts without the frame pass through unchanged.
100
+ * @param text - the replayed content's joined text.
101
+ * @returns the display text without the frame prefix.
102
+ */
103
+ function stripRestoreAssistantFrame(text) {
104
+ const prefix = `${RESTORED_ASSISTANT_NOTICE}\n`;
105
+ return text.startsWith(prefix) ? text.slice(prefix.length) : text;
106
+ }
107
+ /**
71
108
  * Whether the event is a message-tools WITHDRAWAL replacement: a
72
- * `user/message` surface replacement whose source carries this plugin's tag
73
- * and no `op` discriminator (legacy and current withdrawals both).
109
+ * `user/message` surface replacement whose source names this producer
110
+ * (any durable form — see {@link isMessageToolsSource}) with no `op`
111
+ * discriminator.
74
112
  * @param event - the session event to test.
75
113
  * @returns the event narrowed to a withdrawal replacement when matched.
76
114
  */
77
115
  function isMessageToolsReplacement(event) {
78
- return event.type === "user/message" && isReplacementSurfaceEvent(event) && event.data.source.kind === "plugin" && event.data.source.plugin === "message-tools" && messageToolsOp(event.data.source) === void 0;
116
+ return event.type === "user/message" && isReplacementSurfaceEvent(event) && isMessageToolsSource(event.data.source) && messageToolsOp(event.data.source) === void 0;
79
117
  }
80
118
  /**
81
119
  * Whether the event is a message-tools EDIT replacement: a `user/message`
@@ -84,7 +122,7 @@ function isMessageToolsReplacement(event) {
84
122
  * @returns the event narrowed to an edit replacement when matched.
85
123
  */
86
124
  function isMessageToolsEdit(event) {
87
- return event.type === "user/message" && isReplacementSurfaceEvent(event) && event.data.source.kind === "plugin" && event.data.source.plugin === "message-tools" && messageToolsOp(event.data.source) === "edit";
125
+ return event.type === "user/message" && isReplacementSurfaceEvent(event) && isMessageToolsSource(event.data.source) && messageToolsOp(event.data.source) === "edit";
88
126
  }
89
127
  /**
90
128
  * Whether the event is a message-tools restore: an append-surface
@@ -93,7 +131,7 @@ function isMessageToolsEdit(event) {
93
131
  * @returns the event narrowed to a message-tools restore when matched.
94
132
  */
95
133
  function isMessageToolsRestore(event) {
96
- return event.type === "user/message" && event.surfaceOp === "append" && event.data.source.kind === "plugin" && event.data.source.plugin === "message-tools" && messageToolsOp(event.data.source) === void 0;
134
+ return event.type === "user/message" && event.surfaceOp === "append" && isMessageToolsSource(event.data.source) && messageToolsOp(event.data.source) === void 0;
97
135
  }
98
136
  /**
99
137
  * Whether the event is a message-tools ASSISTANT-text restore replay: an
@@ -103,7 +141,115 @@ function isMessageToolsRestore(event) {
103
141
  * @returns the event narrowed to a restore-assistant replay when matched.
104
142
  */
105
143
  function isMessageToolsRestoreAssistant(event) {
106
- return event.type === "user/message" && event.surfaceOp === "append" && event.data.source.kind === "plugin" && event.data.source.plugin === "message-tools" && messageToolsOp(event.data.source) === "restore-assistant";
144
+ return event.type === "user/message" && event.surfaceOp === "append" && isMessageToolsSource(event.data.source) && messageToolsOp(event.data.source) === "restore-assistant";
145
+ }
146
+ //#endregion
147
+ //#region lib/types/restore-projection.js
148
+ /**
149
+ * The `user/message` projection correcting restore replays at derivation
150
+ * time. It registers on the SHARED user-message vocabulary (a custom event
151
+ * type could never persist — S2), so the fold calls `project` synchronously
152
+ * for EVERY user message, inside append validation: it must be a pure
153
+ * pass-through (empty map) for anything without the restore mark, and it must
154
+ * NEVER throw — a throw atomically rejects the candidate append, so a
155
+ * throwing projection would break ordinary user messages instance-wide. Any
156
+ * malformed durable payload therefore also yields an empty map.
157
+ */
158
+ const restoreAssistantProjection = {
159
+ type: "user/message",
160
+ project(event, context) {
161
+ try {
162
+ const source = event.data?.source;
163
+ if (messageToolsOp(source) !== "restore-assistant") return /* @__PURE__ */ new Map();
164
+ const committed = context.events[event.seq - context.baseSeq];
165
+ const message = context.messages.get(event.seq) ?? (committed?.type === "user/message" ? committed.data : void 0) ?? event.data;
166
+ const content = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? {
167
+ ...block,
168
+ text: stripRestoreAssistantFrame(block.text)
169
+ } : block);
170
+ return /* @__PURE__ */ new Map([[event.seq, {
171
+ ...message,
172
+ role: "assistant",
173
+ content
174
+ }]]);
175
+ } catch {
176
+ return /* @__PURE__ */ new Map();
177
+ }
178
+ }
179
+ };
180
+ /** One synthetic user/message event for the fold-semantics probe. */
181
+ function probeEvent(seq, text, source) {
182
+ return {
183
+ type: "user/message",
184
+ seq: SessionSeq(seq),
185
+ time: 0,
186
+ data: createUserMessage({
187
+ content: [{
188
+ type: "text",
189
+ text
190
+ }],
191
+ source
192
+ }),
193
+ surfaceOp: "append"
194
+ };
195
+ }
196
+ /**
197
+ * Whether this host's surface fold can carry a `user/message` projection at
198
+ * all. 0.1.6-alpha.1 routes every event of a projected type through the
199
+ * projection plan and never joins it to the surface (probe-verified): a
200
+ * registered user/message projection would silently drop EVERY user message
201
+ * from the model context. This probe folds two synthetic events through the
202
+ * real `foldSurface` and accepts only the composing semantics the design
203
+ * needs — plain messages keep their node, and a restore replay keeps its
204
+ * node AND derives assistant-role with the frame stripped — so the channel
205
+ * stays dormant on 0.1.6-alpha.1 and lights up by itself on a host whose
206
+ * fold composes. Pure, synchronous, side-effect-free.
207
+ * @param fold - the host's surface fold, injectable for tests.
208
+ * @param derive - the host's single-event derivation, injectable for tests.
209
+ * @returns true when a registered user/message projection is safe.
210
+ */
211
+ function restoreProjectionFoldSupported(fold = sessionApi.foldSurface, derive = sessionApi.deriveEventMessage) {
212
+ try {
213
+ if (typeof fold !== "function" || typeof derive !== "function") return false;
214
+ const events = [probeEvent(0, "probe", { kind: "user" }), probeEvent(1, `${RESTORED_ASSISTANT_NOTICE}\nprobe answer`, restoreAssistantSource())];
215
+ const folded = fold(events, [restoreAssistantProjection]);
216
+ if (folded.nodes.length !== events.length) return false;
217
+ const derived = derive(events[1], folded.projectedMessages);
218
+ return derived?.role === "assistant" && derived.content.some((block) => block.type === "text" && block.text === "probe answer");
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ /**
224
+ * Probe and register the restore-assistant projection, degrading to the
225
+ * framed user-role channel (which the durable events already carry) whenever
226
+ * the host cannot carry the projection: pre-0.1.6 (`registerMessageProjection`
227
+ * absent), a fold that cannot compose projections with surface membership, or
228
+ * a registration conflict (the projection slot is one-per-type per instance —
229
+ * a future official projection claiming user/message throws, and the plugin
230
+ * must not take the instance down with it).
231
+ * @param ctx - host context carrying the session store.
232
+ * @param foldSupported - fold-semantics probe, injectable for tests.
233
+ * @returns true when the projection channel is active.
234
+ */
235
+ function registerRestoreProjection(ctx, foldSupported = restoreProjectionFoldSupported) {
236
+ const sessions = ctx.sessions;
237
+ if (typeof sessions.registerMessageProjection !== "function") {
238
+ ctx.logger.info("message-tools: host has no sessions.registerMessageProjection — restored assistant text stays in the framed user-role channel");
239
+ return false;
240
+ }
241
+ if (!foldSupported()) {
242
+ ctx.logger.warn("message-tools: host message-projection fold drops projected user/message events from the surface — restored assistant text stays in the framed user-role channel");
243
+ return false;
244
+ }
245
+ try {
246
+ const dispose = sessions.registerMessageProjection(restoreAssistantProjection);
247
+ ctx.effect(() => () => dispose(), "message-tools: restore-assistant projection");
248
+ return true;
249
+ } catch {
250
+ ctx.logger.warn("message-tools: a user/message message-projection is already registered — restored assistant text stays in the framed user-role channel");
251
+ return false;
252
+ }
107
253
  }
108
254
  //#endregion
109
255
  //#region lib/types/withdraw.js
@@ -226,10 +372,10 @@ function joinAssistantMessageText(blocks) {
226
372
  * (the message left the surface through another producer, e.g. compaction).
227
373
  *
228
374
  * `sourceEventSeqs` is authoritative for the shadowed *surface* nodes, but an
229
- * interrupted assistant step may exist only as `assistant/chunk` events (no
230
- * `assistant/message` ever landed). Those chunks are not surface nodes, so
231
- * they are absent from `sourceEventSeqs`. The replacement's own `start` and
232
- * `seq` delimit the whole withdrawn log interval — `[start, seq)` — and are
375
+ * interrupted assistant step may exist only as `assistant/attempt` events (no
376
+ * `assistant/message` ever landed). Attempts are not surface nodes, so
377
+ * they are absent from `sourceEventSeqs`. The replacement's own `startSeq` and
378
+ * `seq` delimit the whole withdrawn log interval — `[startSeq, seq)` — and are
233
379
  * still read from the replacement itself, never guessed from another producer.
234
380
  */
235
381
  function findWithdrawnSpan(events, targetSeq) {
@@ -238,7 +384,7 @@ function findWithdrawnSpan(events, targetSeq) {
238
384
  if (event === void 0 || !isMessageToolsReplacement(event)) continue;
239
385
  if (event.sourceEventSeqs?.includes(targetSeq) !== true) continue;
240
386
  const span = [];
241
- for (let seq = event.surfaceOp.start; seq < event.seq; seq++) span.push(seq);
387
+ for (let seq = event.surfaceOp.startSeq; seq < event.seq; seq++) span.push(seq);
242
388
  return span;
243
389
  }
244
390
  }
@@ -249,50 +395,23 @@ function findWithdrawnSpan(events, targetSeq) {
249
395
  * restore-assistant replays already carry the frame). Skipped: withdrawal
250
396
  * placeholders, edit triggers, other plugin context, and tool calls/results.
251
397
  *
252
- * Assistant text normally comes from `assistant/message` surface events. A
253
- * turn that was interrupted before finalization may never emit
254
- * `assistant/message`; its visible content survives only as `assistant/chunk`
255
- * rows. Those chunks are folded into one assistant replay per step when no
256
- * final `assistant/message` exists for that step, so withdrawing/restoring an
257
- * interrupted reply does not lose the partial assistant content.
398
+ * Assistant text normally comes from `assistant/message` surface events (a
399
+ * cancelled turn finalizes its delivered prefix there with `interrupted:
400
+ * true`). An attempt that committed no surface message — failed, retried, or
401
+ * stream-error — settles as an `assistant/attempt` log event carrying its
402
+ * exact compact stream. Each such attempt folds into one assistant replay
403
+ * when no final `assistant/message` exists for that step, so
404
+ * withdrawing/restoring an interrupted reply does not lose the partial
405
+ * assistant content. A retried step's earlier attempts are superseded by the
406
+ * step's final message and do not replay.
258
407
  */
259
408
  function replayEntries(events, spanSeqs) {
260
409
  const entries = [];
261
- let pending;
262
410
  const finalizedSteps = /* @__PURE__ */ new Set();
263
- const flushPending = () => {
264
- if (pending === void 0) return;
265
- if (!pending.finalized) {
266
- const text = [...pending.text.values()].join("");
267
- const reasoning = [...pending.reasoning.values()].join("");
268
- const replay = text !== "" ? text : reasoning;
269
- if (replay !== "") entries.push({
270
- role: "assistant",
271
- text: `${RESTORED_ASSISTANT_NOTICE}\n${replay}`,
272
- sourceSeq: pending.sourceSeq
273
- });
274
- }
275
- pending = void 0;
276
- };
277
- const pushChunk = (event) => {
278
- if (finalizedSteps.has(`${event.data.turn}:${event.data.step}`)) return;
279
- if (pending === void 0 || pending.turn !== event.data.turn || pending.step !== event.data.step) {
280
- flushPending();
281
- pending = {
282
- turn: event.data.turn,
283
- step: event.data.step,
284
- sourceSeq: event.seq,
285
- text: /* @__PURE__ */ new Map(),
286
- reasoning: /* @__PURE__ */ new Map(),
287
- finalized: false
288
- };
289
- }
290
- const chunk = event.data.chunk;
291
- if (chunk.type === "text-delta") pending.text.set(chunk.index, (pending.text.get(chunk.index) ?? "") + chunk.text);
292
- else if (chunk.type === "block-end" && chunk.block.type === "text") pending.text.set(chunk.index, chunk.block.text);
293
- else if (chunk.type === "reasoning-delta") pending.reasoning.set(chunk.index, (pending.reasoning.get(chunk.index) ?? "") + chunk.text);
294
- else if (chunk.type === "block-end" && chunk.block.type === "reasoning") pending.reasoning.set(chunk.index, chunk.block.text);
295
- };
411
+ for (const seq of spanSeqs) {
412
+ const event = events[seq];
413
+ if (event?.type === "assistant/message") finalizedSteps.add(`${event.data.turn}:${event.data.step}`);
414
+ }
296
415
  const pushAssistantText = (text, sourceSeq) => {
297
416
  if (text !== "") entries.push({
298
417
  role: "assistant",
@@ -304,7 +423,6 @@ function replayEntries(events, spanSeqs) {
304
423
  const event = events[seq];
305
424
  if (event === void 0) continue;
306
425
  if (isMessageToolsEdit(event)) {
307
- flushPending();
308
426
  entries.push({
309
427
  role: "user",
310
428
  content: event.data.content,
@@ -312,12 +430,12 @@ function replayEntries(events, spanSeqs) {
312
430
  });
313
431
  continue;
314
432
  }
315
- if (event.type === "assistant/chunk") {
316
- pushChunk(event);
433
+ if (event.type === "assistant/attempt") {
434
+ if (finalizedSteps.has(`${event.data.turn}:${event.data.step}`)) continue;
435
+ pushAssistantText(joinAttemptStreamText(event.data.stream), seq);
317
436
  continue;
318
437
  }
319
438
  if (event.type === "user/message") {
320
- flushPending();
321
439
  if (event.surfaceOp !== "append") continue;
322
440
  if (event.data.source.kind === "user" || isMessageToolsRestore(event)) entries.push({
323
441
  role: "user",
@@ -332,17 +450,40 @@ function replayEntries(events, spanSeqs) {
332
450
  continue;
333
451
  }
334
452
  if (event.type === "assistant/message") {
335
- finalizedSteps.add(`${event.data.turn}:${event.data.step}`);
336
- if (pending !== void 0 && pending.turn === event.data.turn && pending.step === event.data.step) pending.finalized = true;
337
- flushPending();
338
453
  pushAssistantText(joinAssistantMessageText(event.data.message.content), seq);
339
454
  continue;
340
455
  }
341
456
  }
342
- flushPending();
343
457
  return entries;
344
458
  }
345
459
  /**
460
+ * Join one attempt's compact stream into its visible text. Text deltas win;
461
+ * reasoning is the fallback so an attempt that streamed only reasoning does
462
+ * not lose the only assistant content the user saw (the same preference
463
+ * {@link joinAssistantMessageText} applies to finalized messages). Packed
464
+ * delta runs append per block index; a raw `block-end` record carries the
465
+ * assembled block and replaces its index's accumulated deltas. Tool-call
466
+ * runs never replay (their side effects are not replayable).
467
+ */
468
+ function joinAttemptStreamText(stream) {
469
+ const text = /* @__PURE__ */ new Map();
470
+ const reasoning = /* @__PURE__ */ new Map();
471
+ const append = (map, index, fragment) => {
472
+ map.set(index, (map.get(index) ?? "") + fragment);
473
+ };
474
+ for (const record of stream) if (record.type === "text-chunks") append(text, record.index, record.texts.join(""));
475
+ else if (record.type === "reasoning-chunks") append(reasoning, record.index, record.texts.join(""));
476
+ else if (record.type === "chunk") {
477
+ const chunk = record.chunk;
478
+ if (chunk.type === "text-delta") append(text, chunk.index, chunk.text);
479
+ else if (chunk.type === "reasoning-delta") append(reasoning, chunk.index, chunk.text);
480
+ else if (chunk.type === "block-end" && chunk.block.type === "text") text.set(chunk.index, chunk.block.text);
481
+ else if (chunk.type === "block-end" && chunk.block.type === "reasoning") reasoning.set(chunk.index, chunk.block.text);
482
+ }
483
+ const joined = [...text.values()].join("");
484
+ return joined !== "" ? joined : [...reasoning.values()].join("");
485
+ }
486
+ /**
346
487
  * Plan a restore: the target must be an editable user message (the same
347
488
  * vocabulary as withdrawal — original, restore replay, or edit replacement)
348
489
  * that is no longer on the live surface (i.e. withdrawn). Restoring an edit
@@ -428,7 +569,7 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
428
569
  /**
429
570
  * messageTools Remote service: appends the withdrawal replacement to the
430
571
  * owning Session and flushes it durable. The replacement event's
431
- * plugin-tagged source is the audit trail; no new session event type is
572
+ * producer-owned source is the audit trail; no new session event type is
432
573
  * introduced, so session reload stays safe for harnesses without this plugin.
433
574
  */
434
575
  let MessageToolsService = (() => {
@@ -490,10 +631,11 @@ let MessageToolsService = (() => {
490
631
  constructor(ctx) {
491
632
  super(ctx, "messageTools");
492
633
  __runInitializers(this, _instanceExtraInitializers);
634
+ registerRestoreProjection(ctx);
493
635
  }
494
636
  /**
495
637
  * Withdraw a user message: replace it and every surface node after it with
496
- * a minimal plugin-sourced placeholder, hiding the span from the model.
638
+ * a minimal producer-sourced placeholder, hiding the span from the model.
497
639
  * @param request - session identity and target message seq.
498
640
  * @returns the replacement receipt, or a rejection from the closed failure union.
499
641
  */
@@ -513,15 +655,12 @@ let MessageToolsService = (() => {
513
655
  type: "text",
514
656
  text: WITHDRAWN_NOTICE
515
657
  }],
516
- source: {
517
- kind: "plugin",
518
- plugin: MESSAGE_TOOLS_PLUGIN
519
- }
658
+ source: messageToolsSource()
520
659
  }), {
521
660
  surfaceOp: {
522
661
  op: "replace",
523
- start: SessionSeq(planned.plan.start),
524
- end: SessionSeq(planned.plan.end)
662
+ startSeq: SessionSeq(planned.plan.start),
663
+ endSeq: SessionSeq(planned.plan.end)
525
664
  },
526
665
  sourceEventSeqs: planned.plan.sourceEventSeqs.map((seq) => SessionSeq(seq))
527
666
  });
@@ -537,14 +676,14 @@ let MessageToolsService = (() => {
537
676
  /**
538
677
  * Restore a withdrawn span: replay every replayable entry of it (user
539
678
  * messages verbatim — including the last edit's new text — and framed
540
- * assistant text; tool calls/results never replay) as fresh plugin-tagged
541
- * `user/message` appends at the conversation tail, in original order, each
542
- * citing its original event in `sourceEventSeqs`. The surface replace model
543
- * is positional — a replaced span folds into one node — so the span cannot
544
- * be restored in place; the replayed entries are simply the newest messages
545
- * the model sees. Assistant text replays as framed user-role messages:
546
- * `assistant/message` cannot carry a plugin source and the turn/step trace
547
- * forbids assistant appends outside a step.
679
+ * assistant text; tool calls/results never replay) as fresh
680
+ * producer-sourced `user/message` appends at the conversation tail, in
681
+ * original order, each citing its original event in `sourceEventSeqs`. The
682
+ * surface replace model is positional — a replaced span folds into one
683
+ * node — so the span cannot be restored in place; the replayed entries are
684
+ * simply the newest messages the model sees. Assistant text replays as
685
+ * framed user-role messages: `assistant/message` requires the model source
686
+ * and the turn/step trace forbids assistant appends outside a step.
548
687
  * @param request - session identity and withdrawn message seq.
549
688
  * @returns the restore receipt, or a rejection from the closed failure union.
550
689
  */
@@ -563,10 +702,7 @@ let MessageToolsService = (() => {
563
702
  for (const entry of planned.plan.entries) {
564
703
  const message = entry.role === "user" ? createUserMessage({
565
704
  content: [...entry.content],
566
- source: {
567
- kind: "plugin",
568
- plugin: MESSAGE_TOOLS_PLUGIN
569
- }
705
+ source: messageToolsSource()
570
706
  }) : createUserMessage({
571
707
  content: [{
572
708
  type: "text",
@@ -591,7 +727,7 @@ let MessageToolsService = (() => {
591
727
  * with the edited text itself (no placeholder, no tail duplicate — the
592
728
  * edited text appears exactly once, in the replacement event), then start a
593
729
  * regeneration turn. The harness has no wake-without-append path, so the
594
- * turn is started by `agent.followup` carrying a minimal plugin-sourced
730
+ * turn is started by `agent.followup` carrying a minimal producer-sourced
595
731
  * trigger message (the ankh-guard/schedule pattern); the trigger is not the
596
732
  * edited text and lands once, as itself.
597
733
  * @param request - session identity, target seq, edited text.
@@ -617,8 +753,8 @@ let MessageToolsService = (() => {
617
753
  }), {
618
754
  surfaceOp: {
619
755
  op: "replace",
620
- start: SessionSeq(planned.plan.start),
621
- end: SessionSeq(planned.plan.end)
756
+ startSeq: SessionSeq(planned.plan.start),
757
+ endSeq: SessionSeq(planned.plan.end)
622
758
  },
623
759
  sourceEventSeqs: planned.plan.sourceEventSeqs.map((seq) => SessionSeq(seq))
624
760
  });