dsh-edit-turn 0.2.20 → 0.3.0

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.
package/lib/index.js CHANGED
@@ -51,6 +51,17 @@ import { randomUUID } from 'node:crypto'
51
51
  import { readFile } from 'node:fs/promises'
52
52
  import { createRequire } from 'node:module'
53
53
  import { defineTool } from '@deepseek-ai/dsh-tools'
54
+ import * as sessionApi from '@deepseek-ai/dsh-session'
55
+ import {
56
+ PATCH_TYPE,
57
+ buildPatchData,
58
+ editableBlocks,
59
+ kindOfBlock,
60
+ patchList,
61
+ patchProjection,
62
+ patchesOf,
63
+ projectedBlocks,
64
+ } from './patch.js'
54
65
 
55
66
  const nodeRequire = createRequire(import.meta.url)
56
67
  const schemaPackage = (() => {
@@ -69,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
69
80
  export const name = PLUGIN_ID
70
81
 
71
82
  /** Keep in sync with package.json and lib/client.js. */
72
- export const PLUGIN_VERSION = '0.2.20'
83
+ export const PLUGIN_VERSION = '0.3.0'
73
84
 
74
85
  // A ring of the last few requests, answering "did the browser half even ask, and
75
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -145,6 +156,14 @@ export const Config =
145
156
  * step.
146
157
  */
147
158
  confirm: z.boolean().default(false),
159
+ /**
160
+ * How a reply edit lands. `auto` uses the in-place plugin-record
161
+ * projection when the host exposes it and falls back to the historical
162
+ * replay splice otherwise; `patch` refuses to run without the
163
+ * projection; `replay` always uses the old path. Prompt edits always use
164
+ * the official surface replacement.
165
+ */
166
+ replyMode: z.string().default('auto'),
148
167
  })
149
168
 
150
169
  // ---------------------------------------------------------------------------
@@ -237,6 +256,44 @@ export function turnIndex(events) {
237
256
  return turnOf
238
257
  }
239
258
 
259
+ /**
260
+ * One turn bracket: its number, log range, first prompt and reply count.
261
+ *
262
+ * A turn NUMBER is not unique in a log - a replay reuses numbers on fresh
263
+ * brackets - so planning must locate the bracket that CONTAINS an event rather
264
+ * than the first bracket carrying its number (the same rule dsh-rerun-turn
265
+ * learned in 0.1.23; this is the same walk).
266
+ *
267
+ * @param events - complete contiguous raw event log.
268
+ * @returns one entry per `turn/start`, in log order.
269
+ */
270
+ export function turnSpans(events) {
271
+ const spans = []
272
+ let open = null
273
+ for (const event of events) {
274
+ if (event.type === 'turn/start') {
275
+ open = {
276
+ turn: event.data && event.data.turn,
277
+ startSeq: event.seq,
278
+ endSeq: null,
279
+ promptSeq: null,
280
+ replies: 0,
281
+ }
282
+ spans.push(open)
283
+ continue
284
+ }
285
+ if (open === null) continue
286
+ if (event.type === 'turn/end') {
287
+ open.endSeq = event.seq
288
+ open = null
289
+ continue
290
+ }
291
+ if (event.type === 'user/message' && open.promptSeq === null) open.promptSeq = event.seq
292
+ if (event.type === 'assistant/message') open.replies += 1
293
+ }
294
+ return spans
295
+ }
296
+
240
297
  /** The turn still awaiting its `turn/end`, or null when every turn closed. */
241
298
  export function openTurn(events) {
242
299
  let open = null
@@ -710,21 +767,27 @@ function resolveTarget(events, request) {
710
767
  }
711
768
 
712
769
  /**
713
- * Turn one UI target into the canonical rollback window.
770
+ * Turn one UI target into the canonical rollback window plus, for a reply, the
771
+ * replay range that puts the later turns back after the correction.
714
772
  *
715
- * The window is `[the addressed prompt .. the last surface node]`: the complete
716
- * remainder of the conversation in surface order. It is contiguous by
717
- * construction, it always ends on a completed turn's last node, and its left
718
- * edge is exactly the prompt being rewritten, so the surviving prefix ends on
719
- * the previous turn's boundary.
773
+ * A revised PROMPT is replaced in place: its window is the message alone, so
774
+ * the reply under it and everything after stay exactly where they were.
775
+ *
776
+ * A revised REPLY cannot be swapped in place - the format refuses
777
+ * `sourceEventSeqs` on an `assistant/message`, so a new reply can only be
778
+ * APPENDED. The window therefore runs from the addressed reply to the last
779
+ * surface node, and the correction is appended in a fresh turn. Everything the
780
+ * window took is replayed back behind the correction by
781
+ * {@link buildReplayWrites}: the same infix splice dsh-rerun-turn performs, with
782
+ * the user's text instead of a regenerated answer.
720
783
  *
721
784
  * @param events - complete contiguous raw event log.
722
785
  * @param surfaceNodes - current surface seqs in model order.
723
786
  * @param request - `{ seq?, messageId?, turn? }` (`turn` addresses a prompt only).
724
- * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments }`.
787
+ * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments, replayFrom, logTo, turnStart, turnEnd }`.
725
788
  * @throws {EditPlanError} with a stable code when the target cannot be planned.
726
789
  */
727
- export function planRollback(events, surfaceNodes, request) {
790
+ export function planRollback(events, surfaceNodes, request, options = {}) {
728
791
  const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
729
792
  const turnOf = turnIndex(events)
730
793
  const target = resolveTarget(events, request, turnOf)
@@ -737,31 +800,112 @@ export function planRollback(events, surfaceNodes, request) {
737
800
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
738
801
  }
739
802
  // Two editable kinds. A prompt is rewritten in place and the conversation
740
- // continues; a reply is replaced and everything built on it goes with it.
803
+ // continues; a reply is either patched in place through the message projection
804
+ // or, on a host without it, replaced by an appended correction and a replay.
741
805
  const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
742
806
  if (mode === null) {
743
807
  throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
744
808
  }
809
+ const turn = turnOf.get(target.seq)
810
+ const data = target.data && typeof target.data === 'object' ? target.data : {}
811
+ const message = data.message && typeof data.message === 'object' ? data.message : data
812
+ const source = message.source && typeof message.source === 'object' ? message.source : null
813
+ const step = typeof data.step === 'number' ? data.step : 1
814
+ const contentBlocks = readMessageBlocks(target)
815
+
816
+ // The in-place path: one block of the reply, rewritten by a plugin record and
817
+ // its projection. Nothing leaves the surface, so there is no window and no
818
+ // replay; the tool calls and the other blocks stay exactly as recorded.
819
+ if (mode === 'reply' && options.patch === true) {
820
+ const patches = options.patches instanceof Map ? options.patches : new Map()
821
+ const blocks = projectedBlocks(message, target.seq, patches)
822
+ if (blocks.length === 0) {
823
+ throw new EditPlanError('not-editable', 'this reply has no editable block')
824
+ }
825
+ const wanted = typeof request.blockIndex === 'number'
826
+ ? blocks.find((block) => block.index === request.blockIndex)
827
+ : blocks.find((block) => block.kind === 'text') ?? blocks[0]
828
+ if (wanted === undefined) throw new EditPlanError('not-editable', 'the requested block does not exist')
829
+ if (typeof request.kind === 'string' && request.kind !== wanted.kind) {
830
+ throw new EditPlanError('not-editable', 'the requested block changed kind')
831
+ }
832
+ return {
833
+ targetSeq: target.seq,
834
+ messageId: messageIdOf(target),
835
+ mode: 'reply',
836
+ patch: true,
837
+ startSeq: null,
838
+ endSeq: null,
839
+ shadowed: [],
840
+ blockIndex: wanted.index,
841
+ kind: wanted.kind,
842
+ original: wanted.text,
843
+ // `blocks` here are the EDITOR's blocks (index/kind/text/original), not
844
+ // the raw content array; patch mode never rebuilds a message carrier.
845
+ blocks,
846
+ turn: typeof turn === 'number' ? turn : null,
847
+ step,
848
+ provider: source && typeof source.provider === 'string' ? source.provider : null,
849
+ model: source && typeof source.model === 'string' ? source.model : null,
850
+ attachments: contentBlocks.filter((block) => kindOfBlock(block) === null).length,
851
+ }
852
+ }
745
853
  const startIdx = nodeIndex.get(target.seq)
746
854
  // Two different windows, because the two edits mean different things.
747
855
  //
748
856
  // A revised PROMPT is replaced in place: only the message itself leaves the
749
- // surface, so the reply under it and everything after stay exactly as they
750
- // were - edit the wording, keep the conversation. A revised REPLY cannot do
751
- // that (an answer invalidates whatever was built on it), so its window runs to
752
- // the tail and the turn is closed again behind the correction.
857
+ // surface, so the reply under it and everything after stay exactly where they
858
+ // were - edit the wording, keep the conversation.
859
+ //
860
+ // A revised REPLY cannot be a replacement carrier (the format refuses
861
+ // `sourceEventSeqs` on an assistant message), so the correction is appended
862
+ // at the tail and everything from the addressed reply to the end must leave
863
+ // the surface first. `buildReplayWrites` then puts every later turn back,
864
+ // event by event, with fresh turn numbers and message ids - the model reads
865
+ // prefix + corrected reply + the same later turns, in order.
753
866
  const shadowed = mode === 'prompt' ? [target.seq] : surfaceNodes.slice(startIdx)
754
867
  if (shadowed.length === 0 || shadowed[0] !== target.seq) {
755
868
  throw new EditPlanError('not-editable', 'the target does not open a rollback window')
756
869
  }
870
+ // The replay range: the log range the shadow covers, and the bracket where
871
+ // the later turns start. A reply in the last turn has neither, and the edit
872
+ // degenerates to the old tail-less rollback (there is nothing to replay).
873
+ let replayFrom = null
874
+ let logTo = null
875
+ let turnStart = null
876
+ let turnEnd = null
877
+ if (mode === 'reply') {
878
+ const spans = turnSpans(events)
879
+ // A closed bracket that CONTAINS the reply. The fallback (the last bracket
880
+ // that starts before it) only matters for a malformed log with a bracket
881
+ // that never closed; a valid log always takes the containment branch.
882
+ const owning = spans.find(
883
+ (entry) => entry.endSeq !== null && entry.startSeq <= target.seq && target.seq <= entry.endSeq,
884
+ ) ?? spans.filter((entry) => entry.startSeq <= target.seq).at(-1)
885
+ if (owning !== undefined) {
886
+ turnStart = owning.startSeq
887
+ turnEnd = owning.endSeq
888
+ }
889
+ const lastShadowed = shadowed[shadowed.length - 1]
890
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
891
+ logTo = lastShadowed
892
+ for (let seq = lastShadowed + 1; ; seq += 1) {
893
+ const event = bySeq.get(seq)
894
+ if (event === undefined) break
895
+ // The window already ends on the last surface node: only log-only events
896
+ // (step/turn closing records) can follow it.
897
+ if (isSurfaceEvent(event)) break
898
+ logTo = seq
899
+ if (event.type === 'turn/end') break
900
+ }
901
+ const owningIndex = owning === undefined ? -1 : spans.indexOf(owning)
902
+ const nextSpan = owningIndex >= 0 ? spans[owningIndex + 1] : undefined
903
+ if (nextSpan !== undefined && nextSpan.startSeq <= logTo) replayFrom = nextSpan.startSeq
904
+ }
757
905
  const { text, attachments } = readMessageText(target)
758
906
  // The blocks themselves travel with the plan: the carrier is rebuilt from
759
907
  // them, so no caller has to re-read the message to know what it carries.
760
- const blocks = readMessageBlocks(target)
761
- const turn = turnOf.get(target.seq)
762
- const data = target.data && typeof target.data === 'object' ? target.data : {}
763
- const message = data.message && typeof data.message === 'object' ? data.message : data
764
- const source = message.source && typeof message.source === 'object' ? message.source : null
908
+ const blocks = contentBlocks
765
909
  return {
766
910
  targetSeq: target.seq,
767
911
  messageId: messageIdOf(target),
@@ -769,18 +913,305 @@ export function planRollback(events, surfaceNodes, request) {
769
913
  endSeq: shadowed[shadowed.length - 1],
770
914
  shadowed,
771
915
  mode,
916
+ patch: false,
772
917
  turn: typeof turn === 'number' ? turn : null,
773
918
  // The appended correction is keyed by turn:step in the client's reducer, so a
774
919
  // missing step would make two corrections collide on "undefined:undefined".
775
- step: typeof data.step === 'number' ? data.step : 1,
920
+ step,
776
921
  provider: source && typeof source.provider === 'string' ? source.provider : null,
777
922
  model: source && typeof source.model === 'string' ? source.model : null,
778
923
  original: text,
779
924
  attachments,
780
925
  blocks,
926
+ // Reply-only replay range. `replayFrom` is the next turn's bracket (null when
927
+ // the edited reply is in the last turn); `logTo` is the end of the last turn
928
+ // the window shadowed. Both are computed from the ORIGINAL log and stay valid
929
+ // while the correction is appended after it.
930
+ replayFrom,
931
+ logTo,
932
+ turnStart,
933
+ turnEnd,
934
+ }
935
+ }
936
+
937
+ // --- reply replay -----------------------------------------------------------
938
+ //
939
+ // A reply edit lands exactly like a rerun's infix splice: the shadow takes the
940
+ // old answer and every later turn off the surface, the correction is appended in
941
+ // a fresh turn, and this walk puts the later turns back as fresh copies. The
942
+ // algorithm is the one dsh-rerun-turn ships (`buildReplayWrites` there); keep the
943
+ // two in step - `test/replay-parity.test.js` compares their output on one log
944
+ // when the sibling repository is checked out.
945
+
946
+ /** Marker written into every replayed event's source. */
947
+ export const REPLAY_MARKER = PLUGIN_ID
948
+
949
+ /** Text that carries nothing a model could read (spaces, zero-width glue). */
950
+ const INVISIBLE_TEXT_RE = /^[\s\u200b-\u200f\u2060\ufeff]*$/
951
+
952
+ /**
953
+ * Whether content blocks carry anything readable.
954
+ * @param content - the `content` array of a message.
955
+ * @returns true when at least one block is not invisible text.
956
+ */
957
+ export function hasReadableContent(content) {
958
+ if (!Array.isArray(content)) return false
959
+ for (const block of content) {
960
+ if (!block || typeof block !== 'object') continue
961
+ if (block.type !== 'text') return true
962
+ if (typeof block.text !== 'string' || !INVISIBLE_TEXT_RE.test(block.text)) return true
963
+ }
964
+ return false
965
+ }
966
+
967
+ /**
968
+ * Whether an event is a plugin's silent surface carrier rather than conversation.
969
+ *
970
+ * `dsh-delete-turn` writes one for a deletion and `dsh-rerun-turn` for a rerun's
971
+ * shadow - a `user/message` whose source kind is `plugin:<id>` and whose content
972
+ * is empty or invisible. A replay must retire it without a copy: it projects to
973
+ * no model message, so copying it would resurrect an empty user node.
974
+ *
975
+ * A rewritten prompt is NOT this shape: `dsh-edit-turn` keeps `kind: 'user'` and
976
+ * carries the user's wording, so it stays a real prompt and is copied.
977
+ *
978
+ * @param event - raw session event.
979
+ * @returns true for a text-free plugin replacement.
980
+ */
981
+ export function isSilentPluginCarrier(event) {
982
+ if (!event || event.type !== 'user/message') return false
983
+ const data = event.data || {}
984
+ const source = data.source
985
+ if (!source || typeof source.kind !== 'string' || !source.kind.startsWith('plugin:')) return false
986
+ return !hasReadableContent(data.content)
987
+ }
988
+
989
+ /** Stamp one copied event's source with this plugin's replay markers. */
990
+ function replaySource(source, replayId, originalSeq) {
991
+ return {
992
+ ...(source && typeof source === 'object' ? source : {}),
993
+ replayBy: REPLAY_MARKER,
994
+ replayId,
995
+ originalSeq,
781
996
  }
782
997
  }
783
998
 
999
+ /**
1000
+ * Build the replay writes for a reply edit's shadowed tail.
1001
+ *
1002
+ * Every shadowed event after the edited reply is copied verbatim except for:
1003
+ * fresh turn numbers (the log requires them contiguous), fresh message ids,
1004
+ * remapped `sourceEventSeqs` on tool results, dropped usage numbers and embedded
1005
+ * streams, and the replay marker in each message source. System, developer and
1006
+ * every log-only record are skipped: they describe the original run, not the
1007
+ * conversation.
1008
+ *
1009
+ * Tool calls are copied only as complete pairs. A call whose result never landed
1010
+ * (an interrupted turn) is dropped along with its advertisement in the copied
1011
+ * assistant content, because the format refuses a turn that closes with an
1012
+ * unresolved call. The inverse repair shape - a `TOOL_NOT_STARTED` result with no
1013
+ * call event - is copied as-is and keeps its absent `sourceEventSeqs`: the
1014
+ * validator accepts that repair only without one.
1015
+ *
1016
+ * @param events - complete contiguous raw event log (the pre-edit snapshot).
1017
+ * @param plan - the planned window (`shadowed`, `replayFrom`, `logTo`).
1018
+ * @param baseTurn - the first fresh turn number the replay may use.
1019
+ * @param replayId - the operation identity recorded on every copy.
1020
+ * @param startSeq - the seq the first write will land at (writes are appended
1021
+ * back to back, so each predicted seq is `startSeq` + its offset).
1022
+ * @param promptCopied - parity with dsh-rerun-turn's builder: when true, the
1023
+ * plan's `promptSeq` is replayed too. A reply edit never shadows the prompt,
1024
+ * so its own plans leave this false.
1025
+ * @returns an ordered `{ type, data, surfaceOp?, sourceEventSeqs? }` write list.
1026
+ */
1027
+ export function buildReplayWrites(events, plan, baseTurn, replayId, startSeq, promptCopied = false) {
1028
+ const shadowedSet = new Set(plan.shadowed)
1029
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
1030
+ const turnOf = turnIndex(events)
1031
+ const shadowedTurns = new Set()
1032
+ for (const seq of plan.shadowed) {
1033
+ const turn = turnOf.get(seq)
1034
+ if (typeof turn === 'number') shadowedTurns.add(turn)
1035
+ }
1036
+ const included = (event) => {
1037
+ if (isSurfaceEvent(event)) return shadowedSet.has(event.seq)
1038
+ if (
1039
+ event.type === 'turn/start' ||
1040
+ event.type === 'turn/end' ||
1041
+ event.type === 'step/start' ||
1042
+ event.type === 'step/end' ||
1043
+ event.type === 'tool/call'
1044
+ ) {
1045
+ return typeof event.data.turn === 'number' && shadowedTurns.has(event.data.turn)
1046
+ }
1047
+ return false
1048
+ }
1049
+ const range = []
1050
+ // `replayFrom` is this plugin's plan field; `logFrom` is the name the sibling
1051
+ // builder uses, accepted here so the parity test can feed one plan object.
1052
+ const walkFrom = typeof plan.replayFrom === 'number' ? plan.replayFrom : plan.logFrom
1053
+ for (let seq = walkFrom; seq <= plan.logTo; seq += 1) {
1054
+ const event = bySeq.get(seq)
1055
+ if (event === undefined) continue
1056
+ if (included(event)) range.push(event)
1057
+ }
1058
+ // Tool pairing facts for the whole range, so assistant content can be
1059
+ // corrected before anything is written.
1060
+ const calledIds = new Set()
1061
+ const resultIds = new Set()
1062
+ const repairIds = new Set()
1063
+ for (const event of range) {
1064
+ if (event.type === 'tool/call') calledIds.add(event.data.callId)
1065
+ else if (event.type === 'tool/result') {
1066
+ resultIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
1067
+ const error = event.data.error
1068
+ if (error && error.name === 'ToolNotStartedError' && error.code === 'TOOL_NOT_STARTED' && event.sourceEventSeqs === undefined) {
1069
+ repairIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
1070
+ }
1071
+ }
1072
+ }
1073
+ const healthyIds = new Set()
1074
+ for (const id of calledIds) if (resultIds.has(id)) healthyIds.add(id)
1075
+ for (const id of repairIds) if (!calledIds.has(id)) healthyIds.add(id)
1076
+ const pairedResultIds = new Set()
1077
+ for (const id of calledIds) if (resultIds.has(id)) pairedResultIds.add(id)
1078
+ for (const id of repairIds) if (!calledIds.has(id)) pairedResultIds.add(id)
1079
+
1080
+ const writes = []
1081
+ let predicted = startSeq
1082
+ let nextTurn = baseTurn
1083
+ const turnMap = new Map()
1084
+ const assistantSeqById = new Map()
1085
+ let lastAssistantSeq = null
1086
+ const seqOf = () => {
1087
+ const seq = predicted
1088
+ predicted += 1
1089
+ return seq
1090
+ }
1091
+ for (const event of range) {
1092
+ const data = event.data || {}
1093
+ if (event.type === 'turn/start') {
1094
+ turnMap.set(data.turn, nextTurn)
1095
+ writes.push({ type: 'turn/start', data: { turn: nextTurn } })
1096
+ seqOf()
1097
+ continue
1098
+ }
1099
+ if (event.type === 'turn/end') {
1100
+ writes.push({ type: 'turn/end', data: { turn: turnMap.get(data.turn), reason: data.reason } })
1101
+ seqOf()
1102
+ nextTurn += 1
1103
+ continue
1104
+ }
1105
+ if (event.type === 'step/start' || event.type === 'step/end') {
1106
+ writes.push({ type: event.type, data: { turn: turnMap.get(data.turn), step: data.step } })
1107
+ seqOf()
1108
+ continue
1109
+ }
1110
+ if (event.type === 'user/message') {
1111
+ if (event.seq === plan.promptSeq && promptCopied !== true) continue
1112
+ // A silent carrier standing where a prompt was is bookkeeping, not
1113
+ // conversation: retire it without a copy, exactly as rerun does.
1114
+ if (isSilentPluginCarrier(event)) continue
1115
+ writes.push({
1116
+ type: 'user/message',
1117
+ surfaceOp: 'append',
1118
+ data: {
1119
+ id: randomUUID(),
1120
+ role: 'user',
1121
+ content: data.content,
1122
+ source: replaySource(data.source, replayId, event.seq),
1123
+ },
1124
+ })
1125
+ seqOf()
1126
+ continue
1127
+ }
1128
+ if (event.type === 'assistant/message') {
1129
+ const message = data.message || {}
1130
+ const source = { ...(message.source || {}) }
1131
+ delete source.replayState
1132
+ const content = Array.isArray(message.content)
1133
+ ? message.content.filter((block) => !(block && block.type === 'tool-call' && !healthyIds.has(block.id)))
1134
+ : []
1135
+ writes.push({
1136
+ type: 'assistant/message',
1137
+ surfaceOp: 'append',
1138
+ data: {
1139
+ turn: turnMap.get(data.turn),
1140
+ step: data.step,
1141
+ message: {
1142
+ id: randomUUID(),
1143
+ role: 'assistant',
1144
+ content,
1145
+ source: replaySource(source, replayId, event.seq),
1146
+ },
1147
+ stream: [],
1148
+ ...(data.interrupted === true ? { interrupted: true } : {}),
1149
+ },
1150
+ })
1151
+ assistantSeqById.set(event.seq, predicted)
1152
+ lastAssistantSeq = predicted
1153
+ seqOf()
1154
+ continue
1155
+ }
1156
+ if (event.type === 'tool/call') {
1157
+ if (!pairedResultIds.has(data.callId)) continue
1158
+ writes.push({
1159
+ type: 'tool/call',
1160
+ data: { turn: turnMap.get(data.turn), step: data.step, callId: data.callId, name: data.name, arguments: data.arguments },
1161
+ })
1162
+ seqOf()
1163
+ continue
1164
+ }
1165
+ if (event.type === 'tool/result') {
1166
+ const callId = data.message && data.message.source ? data.message.source.callId : data.callId
1167
+ if (!pairedResultIds.has(callId)) continue
1168
+ const message = data.message
1169
+ const source = replaySource(message && message.source, replayId, event.seq)
1170
+ // The TOOL_NOT_STARTED repair is accepted only without sourceEventSeqs;
1171
+ // ordinary results remap theirs onto the copied assistant message.
1172
+ let sourceEventSeqs
1173
+ if (!repairIds.has(callId)) {
1174
+ const remapped = Array.isArray(event.sourceEventSeqs)
1175
+ ? event.sourceEventSeqs.map((seq) => assistantSeqById.get(seq)).filter((seq) => typeof seq === 'number')
1176
+ : []
1177
+ if (remapped.length > 0) sourceEventSeqs = remapped
1178
+ else if (typeof lastAssistantSeq === 'number') sourceEventSeqs = [lastAssistantSeq]
1179
+ }
1180
+ const originalMessage = message || {}
1181
+ const rest = { ...originalMessage }
1182
+ delete rest.id
1183
+ delete rest.content
1184
+ delete rest.source
1185
+ // The interrupted-repair carrier must keep its contract-shaped id
1186
+ // (`interrupted-tool-result-<callId>-<n>`): the validator refuses a repair
1187
+ // whose id does not match, and a random id breaks it. The suffix is the
1188
+ // copy's own predicted seq, so the identity stays self-describing.
1189
+ const messageId = repairIds.has(callId) ? `interrupted-tool-result-${callId}-${predicted}` : randomUUID()
1190
+ writes.push({
1191
+ type: 'tool/result',
1192
+ surfaceOp: 'append',
1193
+ ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
1194
+ data: {
1195
+ turn: turnMap.get(data.turn),
1196
+ step: data.step,
1197
+ message: {
1198
+ ...rest,
1199
+ id: messageId,
1200
+ content: originalMessage.content ?? [],
1201
+ source,
1202
+ },
1203
+ ...(data.error === undefined ? {} : { error: data.error }),
1204
+ ...(data.meta === undefined ? {} : { meta: data.meta }),
1205
+ },
1206
+ })
1207
+ seqOf()
1208
+ continue
1209
+ }
1210
+ // system/message, developer/message and every log-only record are skipped.
1211
+ }
1212
+ return writes
1213
+ }
1214
+
784
1215
  /**
785
1216
  * Every human prompt that can currently be edited.
786
1217
  *
@@ -826,34 +1257,48 @@ export function editableTurns(events, surfaceNodes, options = {}) {
826
1257
  /**
827
1258
  * Every model reply that can currently be rewritten.
828
1259
  *
829
- * Replies with no text at all are skipped: a step that only produced tool calls
830
- * has nothing to put in a text editor. A reply that carries tool calls *and*
831
- * text is offered, because replacing it drops those calls along with it - the
832
- * client warns about that, and the calls cannot survive a rewrite anyway.
1260
+ * In patch mode (the in-place projection path) a reply is offered when it has
1261
+ * any editable block - output text or a reasoning/thinking block - and the
1262
+ * blocks travel with the entry so the editor can pick one. In the legacy replay
1263
+ * mode only text is editable, and a reply without text is skipped.
833
1264
  *
834
1265
  * @param events - complete contiguous raw event log.
835
1266
  * @param surfaceNodes - current surface seqs in model order.
1267
+ * @param options - `{ patches, patch }`: landed patch overlays and whether the
1268
+ * deployment can write a patch record.
836
1269
  * @returns one entry per editable reply, in conversation order.
837
1270
  */
838
- export function editableReplies(events, surfaceNodes) {
1271
+ export function editableReplies(events, surfaceNodes, options = {}) {
839
1272
  const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
840
1273
  const turnOf = turnIndex(events)
841
1274
  const head = surfaceNodes[0]
1275
+ const patches = options.patches instanceof Map ? options.patches : new Map()
1276
+ const patch = options.patch === true
842
1277
  const out = []
843
1278
  for (const event of events) {
844
1279
  if (!isAssistantReply(event)) continue
845
1280
  if (!nodeIndex.has(event.seq)) continue
846
1281
  if (event.seq === head) continue
847
- const { text, attachments } = readMessageText(event)
848
- if (text.trim() === '') continue
1282
+ const data = event.data && typeof event.data === 'object' ? event.data : {}
1283
+ const message = data.message && typeof data.message === 'object' ? data.message : data
1284
+ const blocks = projectedBlocks(message, event.seq, patches)
1285
+ if (blocks.length === 0) continue
1286
+ const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
1287
+ if (!patch && text.trim() === '') continue
1288
+ if (patch && blocks.every((block) => block.text.trim() === '')) continue
849
1289
  const turn = turnOf.get(event.seq)
850
1290
  out.push({
851
1291
  seq: event.seq,
852
1292
  turn: typeof turn === 'number' ? turn : null,
853
1293
  messageId: messageIdOf(event) ?? null,
854
1294
  text,
855
- // For a reply these are the non-text blocks: tool calls and reasoning.
856
- attachments,
1295
+ // For a reply these are the blocks a text-only rewrite would drop:
1296
+ // tool calls and any other non-editable content.
1297
+ attachments: readMessageBlocks(event).filter((block) => patch ? kindOfBlock(block) === null : !isTextBlock(block)).length,
1298
+ patched: blocks.some((block) => block.patched),
1299
+ // Present only where the editor can target one: the replay path can only
1300
+ // replace the whole reply, so it has no block picker.
1301
+ ...(patch ? { blocks } : {}),
857
1302
  })
858
1303
  }
859
1304
  return out
@@ -1199,13 +1644,14 @@ async function stateOf(ctx, sessionId, config) {
1199
1644
  }
1200
1645
  const folded = foldSurface(events)
1201
1646
  const ledger = rollbackLedger(events)
1647
+ const patches = patchesOf(events)
1202
1648
  const live = findLiveSession(ctx, sessionId)
1203
1649
  // Probed per request: the editor offers what THIS deployment can do right
1204
1650
  // now, so a store that is unmounted (or not yet mounted) cannot leave the
1205
1651
  // editor promising to keep blocks the host would have to drop.
1206
1652
  const store = resolveAttachmentStore(ctx)
1207
1653
  const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
1208
- const replies = editableReplies(events, folded.nodes)
1654
+ const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true })
1209
1655
  noteRequest({
1210
1656
  kind: 'state',
1211
1657
  sessionId,
@@ -1227,6 +1673,11 @@ async function stateOf(ctx, sessionId, config) {
1227
1673
  surface: folded.nodes,
1228
1674
  turns,
1229
1675
  replies,
1676
+ // The landed in-place patches, latest per block. The browser half uses them
1677
+ // to draw the projected text in place of the recorded one; the model already
1678
+ // sees them through the host projection.
1679
+ patches: patchList(patches),
1680
+ mode: config.patch === true ? 'patch' : 'replay',
1230
1681
  live: Boolean(live),
1231
1682
  busy: isBusy(events),
1232
1683
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
@@ -1235,7 +1686,7 @@ async function stateOf(ctx, sessionId, config) {
1235
1686
  // path (a chip that may draw its own thumbnail); both are answered from a
1236
1687
  // live service probe, so the editor never promises what the host cannot do.
1237
1688
  capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
1238
- config: { confirm: config.confirm },
1689
+ config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
1239
1690
  version: PLUGIN_VERSION,
1240
1691
  }
1241
1692
  }
@@ -1352,13 +1803,17 @@ async function applyEdit(ctx, sessionId, body, config) {
1352
1803
  if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1353
1804
 
1354
1805
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1806
+ const patches = patchesOf(events)
1807
+ const patchMode = config.patch === true
1355
1808
  let plan
1356
1809
  try {
1357
1810
  plan = planRollback(events, surfaceNodes, {
1358
1811
  seq: typeof body.seq === 'number' ? body.seq : undefined,
1359
1812
  messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1360
1813
  turn: typeof body.turn === 'number' ? body.turn : undefined,
1361
- })
1814
+ blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1815
+ kind: typeof body.kind === 'string' ? body.kind : undefined,
1816
+ }, { patches, patch: patchMode })
1362
1817
  } catch (error) {
1363
1818
  if (error instanceof EditPlanError) {
1364
1819
  const status = error.code === 'not-editable' ? 400 : 409
@@ -1367,6 +1822,56 @@ async function applyEdit(ctx, sessionId, body, config) {
1367
1822
  throw error
1368
1823
  }
1369
1824
 
1825
+ // The in-place reply path: one small plugin record, interpreted by this
1826
+ // plugin's projection. Nothing leaves the surface, so there is no window to
1827
+ // check, no turn to open and no tail to replay. The record is append-only and
1828
+ // the original event keeps its bytes; the model sees the patched block on the
1829
+ // next fold.
1830
+ if (plan.patch === true) {
1831
+ if (typeof sessionApi.appendPluginRecord !== 'function') {
1832
+ throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
1833
+ }
1834
+ if (text === plan.original) {
1835
+ return {
1836
+ kind: 'reply',
1837
+ applied: false,
1838
+ unchanged: true,
1839
+ patch: true,
1840
+ shadowed: [],
1841
+ blockIndex: plan.blockIndex,
1842
+ kindOfBlock: plan.kind,
1843
+ original: plan.original,
1844
+ }
1845
+ }
1846
+ let recordSeq
1847
+ try {
1848
+ recordSeq = sessionApi.appendPluginRecord(
1849
+ session,
1850
+ PATCH_TYPE,
1851
+ buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
1852
+ )
1853
+ } catch (error) {
1854
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
1855
+ }
1856
+ const flush = await flushSession(ctx, session)
1857
+ return {
1858
+ kind: 'reply',
1859
+ applied: true,
1860
+ mode: 'patch',
1861
+ patch: true,
1862
+ recordSeq,
1863
+ target: {
1864
+ seq: plan.targetSeq,
1865
+ blockIndex: plan.blockIndex,
1866
+ kind: plan.kind,
1867
+ text,
1868
+ original: plan.original,
1869
+ },
1870
+ shadowed: [],
1871
+ ...flush,
1872
+ }
1873
+ }
1874
+
1370
1875
  // The live surface is the append authority; a node that vanished between the
1371
1876
  // read and this check means another writer landed first.
1372
1877
  for (const seq of plan.shadowed) {
@@ -1465,6 +1970,13 @@ async function applyEdit(ctx, sessionId, body, config) {
1465
1970
  // (it projects `lastTurn` from every turn/start it observes). A prompt edit
1466
1971
  // does not: `user/message` carries the revision and needs no turn.
1467
1972
  const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
1973
+ // One identity for the whole reply splice: every copy the replay appends
1974
+ // carries it, so a later reader (or a sibling plugin) can tell a replayed turn
1975
+ // from the one it retired without re-deriving anything.
1976
+ const replayId = plan.mode === 'reply' ? randomUUID() : null
1977
+ // Resolved BEFORE the appends so the loop counter can be re-pointed in the
1978
+ // same tick as the writes, not after an await that lets the loop start.
1979
+ const loopAgent = plan.mode === 'reply' ? await resolveLoopAgent(ctx, sessionId) : undefined
1468
1980
  const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config, content)
1469
1981
  if (plan.mode === 'reply') {
1470
1982
  try {
@@ -1495,82 +2007,122 @@ async function applyEdit(ctx, sessionId, body, config) {
1495
2007
  } catch (error) {
1496
2008
  throw new HttpError(409, 'stale', `the surface refused the rollback: ${String((error && error.message) || error)}`)
1497
2009
  }
1498
- const flush = await flushSession(ctx, session)
1499
2010
 
1500
- // Step two depends on what was edited.
1501
- //
1502
- // Rewriting a reply cannot swap the text in place - an `assistant/message` may
1503
- // never carry `sourceEventSeqs`, so it can never be a replacement carrier
1504
- // (checked against the real validator, not assumed). The correction is
1505
- // appended instead, in the same shape the agent loop records a reply, so the
1506
- // model goes on seeing the edited text as its own answer and the conversation
1507
- // continues from there. Nothing is re-run: the answer was fixed, the model was
1508
- // not asked again.
1509
- if (plan.mode === 'reply') {
1510
- const correction = buildCorrection(plan, text, editTurn, 1)
1511
- let appended
2011
+ // A prompt edit saved here: the carrier that replaced the window IS the
2012
+ // revised prompt, so the model reads it as the user's wording from here on.
2013
+ // Nothing is prompted and nothing else needs writing.
2014
+ if (plan.mode === 'prompt') {
2015
+ const flush = await flushSession(ctx, session)
2016
+ return {
2017
+ kind: 'prompt',
2018
+ replacementSeq: replacement.seq,
2019
+ applied: true,
2020
+ shadowed: plan.shadowed,
2021
+ dropped,
2022
+ // What the carrier now carries, described exactly the way /state describes
2023
+ // it. The browser half adopts this for the row it re-draws before its own
2024
+ // refresh lands - without it a second save could not know which blocks are
2025
+ // still there and would rewrite the message text-only.
2026
+ blocks: blockSummaries(content, canPreviewAttachments(store)),
2027
+ ...flush,
2028
+ }
2029
+ }
2030
+
2031
+ // Step two of a reply edit: the correction itself, inside the turn just
2032
+ // opened. An `assistant/message` may never carry `sourceEventSeqs`, so it can
2033
+ // never be a replacement carrier (checked against the real validator, not
2034
+ // assumed); it is appended in the same shape the agent loop records a reply,
2035
+ // and the model goes on seeing the edited text as its own answer.
2036
+ const correction = buildCorrection(plan, text, editTurn, 1)
2037
+ let appended
2038
+ try {
2039
+ appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
2040
+ // Close the step and the turn behind the corrected reply: the host anchors
2041
+ // a turn's tail (duration, action strip) at its last `turn/end`, and the
2042
+ // format only reads a step message inside an open turn and step.
2043
+ session.append('step/end', { turn: editTurn, step: 1 })
2044
+ session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
2045
+ } catch (error) {
2046
+ const failedFlush = await flushSession(ctx, session)
2047
+ return {
2048
+ kind: 'reply',
2049
+ replacementSeq: replacement.seq,
2050
+ applied: false,
2051
+ applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
2052
+ shadowed: plan.shadowed,
2053
+ dropped,
2054
+ ...failedFlush,
2055
+ }
2056
+ }
2057
+
2058
+ // Step three: put every later turn back behind the correction, as fresh
2059
+ // copies. This is the same infix splice dsh-rerun-turn performs after a
2060
+ // regenerated answer - shadow the tail, write the new reply, replay what
2061
+ // followed - with the user's text taking the place of the model call.
2062
+ let replay = null
2063
+ if (typeof plan.replayFrom === 'number' && typeof plan.logTo === 'number') {
2064
+ const writes = buildReplayWrites(events, plan, editTurn + 1, replayId, session.seq)
2065
+ let replayed = 0
2066
+ let replayError = null
1512
2067
  try {
1513
- appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
1514
- // Close the step and the turn behind the corrected reply: the host anchors
1515
- // a turn's tail (duration, action strip) at its last `turn/end`, and the
1516
- // format only reads a step message inside an open turn and step.
1517
- session.append('step/end', { turn: editTurn, step: 1 })
1518
- session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
1519
- } catch (error) {
1520
- return {
1521
- kind: 'reply',
1522
- replacementSeq: replacement.seq,
1523
- applied: false,
1524
- applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
1525
- shadowed: plan.shadowed,
1526
- dropped,
1527
- ...flush,
2068
+ for (const write of writes) {
2069
+ const options = {}
2070
+ if (write.surfaceOp !== undefined) options.surfaceOp = write.surfaceOp
2071
+ if (write.sourceEventSeqs !== undefined) options.sourceEventSeqs = write.sourceEventSeqs
2072
+ session.append(write.type, write.data, options)
2073
+ replayed += 1
1528
2074
  }
2075
+ } catch (error) {
2076
+ replayError = String((error && error.message) || error)
2077
+ }
2078
+ const playedMax = writes.reduce((max, write) => {
2079
+ const turn = write.data && typeof write.data.turn === 'number' ? write.data.turn : 0
2080
+ return turn > max ? turn : max
2081
+ }, 0)
2082
+ replay = {
2083
+ id: replayId,
2084
+ replayed,
2085
+ total: writes.length,
2086
+ turns: writes.reduce((count, write) => count + (write.type === 'turn/start' ? 1 : 0), 0),
2087
+ ...(replayError === null ? {} : { error: replayError }),
2088
+ }
2089
+ // The replayed turns are closed in the log but were never opened by the
2090
+ // live loop: re-point its idle counter at the highest one, synchronously.
2091
+ const loopTurn = syncLoopTurn(loopAgent, playedMax > editTurn ? playedMax : editTurn)
2092
+ if (loopTurn === 'unavailable') {
2093
+ noteRequest({ kind: 'reply', sessionId, ok: replayError === null, loopTurn, turn: editTurn, replayId, replayed })
1529
2094
  }
1530
2095
  const corrected = await flushSession(ctx, session)
1531
- // The synthetic turn this edit opened is invisible to the live loop's own
1532
- // counter; without the sync its next run reuses that number and the log
1533
- // fails its next load. Only a reply edit opens a turn - a prompt edit does
1534
- // not, and needs nothing.
1535
- const loopAgent = await resolveLoopAgent(ctx, sessionId)
1536
- const loopTurn = syncLoopTurn(loopAgent, editTurn)
1537
- // `loopTurn` is the diagnostic the request ring records; the field is DELIBERATELY
1538
- // not called `code` - the static checks read `code:` as host error codes and
1539
- // must not start demanding a user-visible message for an internal sync state.
1540
- const syncNote = loopTurn
1541
- if (syncNote === 'unavailable') {
1542
- noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn: syncNote, turn: editTurn })
1543
- }
1544
2096
  return {
1545
2097
  kind: 'reply',
1546
2098
  replacementSeq: replacement.seq,
1547
2099
  appendedSeq: appended.seq,
1548
2100
  applied: true,
1549
2101
  loopTurn,
2102
+ replay,
1550
2103
  shadowed: plan.shadowed,
1551
2104
  dropped,
1552
2105
  ...corrected,
1553
2106
  }
1554
2107
  }
1555
2108
 
1556
- // The rollback above already did the whole job: the carrier that replaced the
1557
- // window *is* the revised prompt, so the model reads it as the user's wording
1558
- // from here on. Saving therefore costs no model call, always: regenerating
1559
- // the turn is a different operation with a different owner - the sibling
1560
- // plugin dsh-rerun-turn shadows the turn and regenerates it from the prompt
1561
- // the surface now shows, then replays what followed. Nothing here prompts.
2109
+ // No later turn to replay: the correction is the last answer in the log.
2110
+ // The synthetic turn still moved the loop's counter; sync it the same way.
2111
+ const loopTurn = syncLoopTurn(loopAgent, editTurn)
2112
+ if (loopTurn === 'unavailable') {
2113
+ noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn, turn: editTurn, replayId })
2114
+ }
2115
+ const corrected = await flushSession(ctx, session)
1562
2116
  return {
1563
- kind: 'prompt',
2117
+ kind: 'reply',
1564
2118
  replacementSeq: replacement.seq,
2119
+ appendedSeq: appended.seq,
1565
2120
  applied: true,
2121
+ loopTurn,
2122
+ replay: null,
1566
2123
  shadowed: plan.shadowed,
1567
2124
  dropped,
1568
- // What the carrier now carries, described exactly the way /state describes
1569
- // it. The browser half adopts this for the row it re-draws before its own
1570
- // refresh lands - without it a second save could not know which blocks are
1571
- // still there and would rewrite the message text-only.
1572
- blocks: blockSummaries(content, canPreviewAttachments(store)),
1573
- ...flush,
2125
+ ...corrected,
1574
2126
  }
1575
2127
  }
1576
2128
 
@@ -1590,14 +2142,14 @@ function sessionIdFromExec(exec) {
1590
2142
  return undefined
1591
2143
  }
1592
2144
 
1593
- function registerTool(ctx) {
2145
+ function registerTool(ctx, config) {
1594
2146
  const tools = ctx.get('tools')
1595
2147
  if (!tools || typeof tools.register !== 'function') return
1596
2148
  tools.register(
1597
2149
  defineTool({
1598
2150
  name: 'edit_turn_targets',
1599
2151
  description:
1600
- 'List the user turns of a DeepSeek Harness session that the edit-turn plugin can rewrite and re-run, with each prompt\'s original text. Read-only: it changes nothing.',
2152
+ 'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text and, for a reply, every editable block (output text and reasoning). A prompt is replaced where it stands; a reply block is patched in place when the host supports projections, otherwise the reply correction is appended and the later turns are replayed behind it. Read-only: it changes nothing.',
1601
2153
  parameters: {
1602
2154
  sessionId: {
1603
2155
  type: 'string',
@@ -1617,8 +2169,9 @@ function registerTool(ctx) {
1617
2169
  const events = await readEvents(ctx, sessionId)
1618
2170
  if (!events) throw new Error(`no session log for ${sessionId}`)
1619
2171
  const nodes = surfaceOf(ctx, sessionId, events)
2172
+ const patches = patchesOf(events)
1620
2173
  const turns = editableTurns(events, nodes)
1621
- const replies = editableReplies(events, nodes)
2174
+ const replies = editableReplies(events, nodes, { patches, patch: config && config.patch === true })
1622
2175
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
1623
2176
  const shown = turns.slice(-limit)
1624
2177
  const shownReplies = replies.slice(-limit)
@@ -1632,10 +2185,13 @@ function registerTool(ctx) {
1632
2185
  if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
1633
2186
  for (const reply of shownReplies) {
1634
2187
  const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
1635
- lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s)): ${preview}`)
2188
+ const blocks = Array.isArray(reply.blocks)
2189
+ ? ` [${reply.blocks.map((block) => `${block.index}:${block.kind}${block.patched ? '*' : ''}`).join(', ')}]`
2190
+ : ''
2191
+ lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s))${blocks}: ${preview}`)
1636
2192
  }
1637
2193
  if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
1638
- lines.push('editing a turn replaces its wording in place (no model call); editing a reply replaces its text and discards what follows. Re-running a turn is dsh-rerun-turn\'s button.')
2194
+ lines.push('editing a turn replaces its wording in place (no model call); editing a reply block patches it in place when the host supports plugin-record projections, otherwise it replaces the reply and replays the tail. Re-running a turn is dsh-rerun-turn\'s button.')
1639
2195
  return lines.join('\n')
1640
2196
  },
1641
2197
  }),
@@ -1764,7 +2320,38 @@ export function apply(ctx, config) {
1764
2320
  : DEFAULT_MARKER,
1765
2321
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
1766
2322
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
2323
+ replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
2324
+ }
2325
+
2326
+ // The in-place reply path needs two host seams: a plugin record with the
2327
+ // `ignorable` envelope, and a message projection registry. Both are probed at
2328
+ // load; when either is missing the reply path keeps the official replay
2329
+ // splice, and `replyMode: 'patch'` turns that silence into a load error.
2330
+ const sessionsService = ctx.get('sessions')
2331
+ let patch = false
2332
+ if (
2333
+ resolved.replyMode !== 'replay' &&
2334
+ typeof sessionApi.appendPluginRecord === 'function' &&
2335
+ sessionsService &&
2336
+ typeof sessionsService.registerMessageProjection === 'function'
2337
+ ) {
2338
+ try {
2339
+ // A live reload (HMR, a re-applied bundle) leaves the previous
2340
+ // registration in place: the registry rejects a second definition of the
2341
+ // same type. Reuse ours instead of failing back to replay - the
2342
+ // projection is a stateless pure function, so the old instance is exactly
2343
+ // as good as a fresh one.
2344
+ const registry = sessionsService.messageProjections
2345
+ const existing = Array.isArray(registry)
2346
+ ? registry.find((entry) => entry && entry.type === PATCH_TYPE)
2347
+ : undefined
2348
+ if (existing === undefined) sessionsService.registerMessageProjection(patchProjection())
2349
+ patch = true
2350
+ } catch (error) {
2351
+ if (resolved.replyMode === 'patch') throw error
2352
+ }
1767
2353
  }
2354
+ resolved.patch = patch
1768
2355
 
1769
2356
  const registerRoutes = (webServer, fiber) => {
1770
2357
  fiber.effect(() =>
@@ -1843,7 +2430,19 @@ export function apply(ctx, config) {
1843
2430
  sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
1844
2431
  return
1845
2432
  }
1846
- sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, requests: recentRequests() })
2433
+ sendJson(res, 200, {
2434
+ ok: true,
2435
+ version: PLUGIN_VERSION,
2436
+ // How this deployment resolves a reply edit. `patch` means both
2437
+ // host seams were found and the projection registered; `replay`
2438
+ // means the historical splice is what a save will use.
2439
+ mode: resolved.patch === true ? 'patch' : 'replay',
2440
+ capabilities: {
2441
+ pluginRecords: typeof sessionApi.appendPluginRecord === 'function',
2442
+ projection: resolved.patch === true,
2443
+ },
2444
+ requests: recentRequests(),
2445
+ })
1847
2446
  },
1848
2447
  }),
1849
2448
  )
@@ -1924,9 +2523,9 @@ export function apply(ctx, config) {
1924
2523
 
1925
2524
  const tools = ctx.get('tools')
1926
2525
  if (tools) {
1927
- registerTool(ctx)
2526
+ registerTool(ctx, resolved)
1928
2527
  } else {
1929
- ctx.inject(['tools'], (sub) => registerTool(sub))
2528
+ ctx.inject(['tools'], (sub) => registerTool(sub, resolved))
1930
2529
  }
1931
2530
  }
1932
2531