@krischoichoi/channel-harness 0.6.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.
Files changed (202) hide show
  1. package/README.md +110 -0
  2. package/lib/access/controller.d.ts +37 -0
  3. package/lib/access/controller.d.ts.map +1 -0
  4. package/lib/access/controller.js +51 -0
  5. package/lib/access/controller.js.map +1 -0
  6. package/lib/access/decision.d.ts +18 -0
  7. package/lib/access/decision.d.ts.map +1 -0
  8. package/lib/access/decision.js +2 -0
  9. package/lib/access/decision.js.map +1 -0
  10. package/lib/access/resolver.d.ts +41 -0
  11. package/lib/access/resolver.d.ts.map +1 -0
  12. package/lib/access/resolver.js +31 -0
  13. package/lib/access/resolver.js.map +1 -0
  14. package/lib/agent-manager.d.ts +314 -0
  15. package/lib/agent-manager.d.ts.map +1 -0
  16. package/lib/agent-manager.js +603 -0
  17. package/lib/agent-manager.js.map +1 -0
  18. package/lib/agent-router.d.ts +37 -0
  19. package/lib/agent-router.d.ts.map +1 -0
  20. package/lib/agent-router.js +33 -0
  21. package/lib/agent-router.js.map +1 -0
  22. package/lib/bind-support.d.ts +23 -0
  23. package/lib/bind-support.d.ts.map +1 -0
  24. package/lib/bind-support.js +63 -0
  25. package/lib/bind-support.js.map +1 -0
  26. package/lib/binding-store.d.ts +104 -0
  27. package/lib/binding-store.d.ts.map +1 -0
  28. package/lib/binding-store.js +354 -0
  29. package/lib/binding-store.js.map +1 -0
  30. package/lib/bridge.d.ts +248 -0
  31. package/lib/bridge.d.ts.map +1 -0
  32. package/lib/bridge.js +961 -0
  33. package/lib/bridge.js.map +1 -0
  34. package/lib/channel-label.d.ts +46 -0
  35. package/lib/channel-label.d.ts.map +1 -0
  36. package/lib/channel-label.js +89 -0
  37. package/lib/channel-label.js.map +1 -0
  38. package/lib/channel-session-factory.d.ts +76 -0
  39. package/lib/channel-session-factory.d.ts.map +1 -0
  40. package/lib/channel-session-factory.js +187 -0
  41. package/lib/channel-session-factory.js.map +1 -0
  42. package/lib/commands/bind.d.ts +21 -0
  43. package/lib/commands/bind.d.ts.map +1 -0
  44. package/lib/commands/bind.js +91 -0
  45. package/lib/commands/bind.js.map +1 -0
  46. package/lib/commands/help.d.ts +15 -0
  47. package/lib/commands/help.d.ts.map +1 -0
  48. package/lib/commands/help.js +89 -0
  49. package/lib/commands/help.js.map +1 -0
  50. package/lib/commands/index.d.ts +122 -0
  51. package/lib/commands/index.d.ts.map +1 -0
  52. package/lib/commands/index.js +63 -0
  53. package/lib/commands/index.js.map +1 -0
  54. package/lib/commands/locale.d.ts +9 -0
  55. package/lib/commands/locale.d.ts.map +1 -0
  56. package/lib/commands/locale.js +27 -0
  57. package/lib/commands/locale.js.map +1 -0
  58. package/lib/commands/mirror.d.ts +14 -0
  59. package/lib/commands/mirror.d.ts.map +1 -0
  60. package/lib/commands/mirror.js +44 -0
  61. package/lib/commands/mirror.js.map +1 -0
  62. package/lib/commands/model.d.ts +20 -0
  63. package/lib/commands/model.d.ts.map +1 -0
  64. package/lib/commands/model.js +106 -0
  65. package/lib/commands/model.js.map +1 -0
  66. package/lib/commands/models.d.ts +14 -0
  67. package/lib/commands/models.d.ts.map +1 -0
  68. package/lib/commands/models.js +67 -0
  69. package/lib/commands/models.js.map +1 -0
  70. package/lib/commands/new.d.ts +14 -0
  71. package/lib/commands/new.d.ts.map +1 -0
  72. package/lib/commands/new.js +37 -0
  73. package/lib/commands/new.js.map +1 -0
  74. package/lib/commands/status.d.ts +12 -0
  75. package/lib/commands/status.d.ts.map +1 -0
  76. package/lib/commands/status.js +30 -0
  77. package/lib/commands/status.js.map +1 -0
  78. package/lib/commands/stop.d.ts +16 -0
  79. package/lib/commands/stop.d.ts.map +1 -0
  80. package/lib/commands/stop.js +27 -0
  81. package/lib/commands/stop.js.map +1 -0
  82. package/lib/commands/version.d.ts +25 -0
  83. package/lib/commands/version.d.ts.map +1 -0
  84. package/lib/commands/version.js +57 -0
  85. package/lib/commands/version.js.map +1 -0
  86. package/lib/config.d.ts +119 -0
  87. package/lib/config.d.ts.map +1 -0
  88. package/lib/config.js +60 -0
  89. package/lib/config.js.map +1 -0
  90. package/lib/debug-logger.d.ts +8 -0
  91. package/lib/debug-logger.d.ts.map +1 -0
  92. package/lib/debug-logger.js +32 -0
  93. package/lib/debug-logger.js.map +1 -0
  94. package/lib/dsh-home.d.ts +12 -0
  95. package/lib/dsh-home.d.ts.map +1 -0
  96. package/lib/dsh-home.js +31 -0
  97. package/lib/dsh-home.js.map +1 -0
  98. package/lib/failure-display.d.ts +10 -0
  99. package/lib/failure-display.d.ts.map +1 -0
  100. package/lib/failure-display.js +42 -0
  101. package/lib/failure-display.js.map +1 -0
  102. package/lib/file-provider.d.ts +96 -0
  103. package/lib/file-provider.d.ts.map +1 -0
  104. package/lib/file-provider.js +44 -0
  105. package/lib/file-provider.js.map +1 -0
  106. package/lib/index.d.ts +31 -0
  107. package/lib/index.d.ts.map +1 -0
  108. package/lib/index.js +31 -0
  109. package/lib/index.js.map +1 -0
  110. package/lib/interactions/index.d.ts +13 -0
  111. package/lib/interactions/index.d.ts.map +1 -0
  112. package/lib/interactions/index.js +13 -0
  113. package/lib/interactions/index.js.map +1 -0
  114. package/lib/interactions/question-backend.d.ts +133 -0
  115. package/lib/interactions/question-backend.d.ts.map +1 -0
  116. package/lib/interactions/question-backend.js +37 -0
  117. package/lib/interactions/question-backend.js.map +1 -0
  118. package/lib/interactions/question-presenter.d.ts +68 -0
  119. package/lib/interactions/question-presenter.d.ts.map +1 -0
  120. package/lib/interactions/question-presenter.js +425 -0
  121. package/lib/interactions/question-presenter.js.map +1 -0
  122. package/lib/interactions/question-renderer.d.ts +37 -0
  123. package/lib/interactions/question-renderer.d.ts.map +1 -0
  124. package/lib/interactions/question-renderer.js +79 -0
  125. package/lib/interactions/question-renderer.js.map +1 -0
  126. package/lib/interactions/question-state.d.ts +106 -0
  127. package/lib/interactions/question-state.d.ts.map +1 -0
  128. package/lib/interactions/question-state.js +99 -0
  129. package/lib/interactions/question-state.js.map +1 -0
  130. package/lib/interactions/question-text-answer.d.ts +37 -0
  131. package/lib/interactions/question-text-answer.d.ts.map +1 -0
  132. package/lib/interactions/question-text-answer.js +70 -0
  133. package/lib/interactions/question-text-answer.js.map +1 -0
  134. package/lib/interactions/question-waterfall-backend.d.ts +42 -0
  135. package/lib/interactions/question-waterfall-backend.d.ts.map +1 -0
  136. package/lib/interactions/question-waterfall-backend.js +162 -0
  137. package/lib/interactions/question-waterfall-backend.js.map +1 -0
  138. package/lib/lifecycle.d.ts +36 -0
  139. package/lib/lifecycle.d.ts.map +1 -0
  140. package/lib/lifecycle.js +303 -0
  141. package/lib/lifecycle.js.map +1 -0
  142. package/lib/loggable-error.d.ts +10 -0
  143. package/lib/loggable-error.d.ts.map +1 -0
  144. package/lib/loggable-error.js +32 -0
  145. package/lib/loggable-error.js.map +1 -0
  146. package/lib/message-converter.d.ts +64 -0
  147. package/lib/message-converter.d.ts.map +1 -0
  148. package/lib/message-converter.js +191 -0
  149. package/lib/message-converter.js.map +1 -0
  150. package/lib/model-selection.d.ts +54 -0
  151. package/lib/model-selection.d.ts.map +1 -0
  152. package/lib/model-selection.js +116 -0
  153. package/lib/model-selection.js.map +1 -0
  154. package/lib/outbox/binding-resolver.d.ts +23 -0
  155. package/lib/outbox/binding-resolver.d.ts.map +1 -0
  156. package/lib/outbox/binding-resolver.js +38 -0
  157. package/lib/outbox/binding-resolver.js.map +1 -0
  158. package/lib/outbox/capabilities.d.ts +48 -0
  159. package/lib/outbox/capabilities.d.ts.map +1 -0
  160. package/lib/outbox/capabilities.js +35 -0
  161. package/lib/outbox/capabilities.js.map +1 -0
  162. package/lib/outbox/index.d.ts +14 -0
  163. package/lib/outbox/index.d.ts.map +1 -0
  164. package/lib/outbox/index.js +14 -0
  165. package/lib/outbox/index.js.map +1 -0
  166. package/lib/outbox/service.d.ts +48 -0
  167. package/lib/outbox/service.d.ts.map +1 -0
  168. package/lib/outbox/service.js +74 -0
  169. package/lib/outbox/service.js.map +1 -0
  170. package/lib/outbox/target.d.ts +17 -0
  171. package/lib/outbox/target.d.ts.map +1 -0
  172. package/lib/outbox/target.js +15 -0
  173. package/lib/outbox/target.js.map +1 -0
  174. package/lib/outbox/tool-send.d.ts +19 -0
  175. package/lib/outbox/tool-send.d.ts.map +1 -0
  176. package/lib/outbox/tool-send.js +77 -0
  177. package/lib/outbox/tool-send.js.map +1 -0
  178. package/lib/outbox/types.d.ts +42 -0
  179. package/lib/outbox/types.d.ts.map +1 -0
  180. package/lib/outbox/types.js +30 -0
  181. package/lib/outbox/types.js.map +1 -0
  182. package/lib/plugin.d.ts +27 -0
  183. package/lib/plugin.d.ts.map +1 -0
  184. package/lib/plugin.js +47 -0
  185. package/lib/plugin.js.map +1 -0
  186. package/lib/reply-context-store.d.ts +69 -0
  187. package/lib/reply-context-store.d.ts.map +1 -0
  188. package/lib/reply-context-store.js +75 -0
  189. package/lib/reply-context-store.js.map +1 -0
  190. package/lib/reply-router.d.ts +138 -0
  191. package/lib/reply-router.d.ts.map +1 -0
  192. package/lib/reply-router.js +674 -0
  193. package/lib/reply-router.js.map +1 -0
  194. package/lib/session-router.d.ts +49 -0
  195. package/lib/session-router.d.ts.map +1 -0
  196. package/lib/session-router.js +45 -0
  197. package/lib/session-router.js.map +1 -0
  198. package/lib/workspace-resolver.d.ts +67 -0
  199. package/lib/workspace-resolver.d.ts.map +1 -0
  200. package/lib/workspace-resolver.js +102 -0
  201. package/lib/workspace-resolver.js.map +1 -0
  202. package/package.json +103 -0
@@ -0,0 +1,674 @@
1
+ import { joinAssistantStreamText } from '@deepseek-ai/dsh-llm';
2
+ import { formatChannelTurnFailure } from './failure-display.js';
3
+ export class ReplyRouter {
4
+ options;
5
+ active = new Map();
6
+ streamTurns = new Map();
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ /** Register the `session/event` listener; returns a disposer. */
11
+ attach(ctx) {
12
+ const stopSessionEvents = ctx.on('session/event', (session, event) => {
13
+ this.onSessionEvent(session, event);
14
+ });
15
+ const stopAssistantStream = ctx.on('agent/assistant-stream', ({ agent, frame }) => {
16
+ this.onAssistantStream(agent, frame);
17
+ });
18
+ return () => stopSessionEvents() && stopAssistantStream();
19
+ }
20
+ /** Consume transient V3 stream frames for native/edit previews. */
21
+ onAssistantStream(agent, frame) {
22
+ const key = `${agent.id}:${frame.attemptId}`;
23
+ if (frame.type === 'start') {
24
+ this.streamTurns.set(key, frame.turn);
25
+ return;
26
+ }
27
+ if (frame.type === 'end') {
28
+ this.streamTurns.delete(key);
29
+ return;
30
+ }
31
+ if (frame.chunk.type !== 'text-delta')
32
+ return;
33
+ const turn = this.streamTurns.get(key);
34
+ if (turn === undefined)
35
+ return;
36
+ const active = this.ensureActive(agent.session, turn);
37
+ if (active)
38
+ this.appendText(active, frame.chunk.text);
39
+ }
40
+ /** Testable entry point; the registered listener delegates here. */
41
+ onSessionEvent(session, event) {
42
+ switch (event.type) {
43
+ case 'turn/start':
44
+ // Turn existence only — no active reply is established here. Outbound
45
+ // delivery requires a Channel ReplyContext (message-id → claimed →
46
+ // turn), which is only authoritative once chunks flow. A non-channel
47
+ // turn on a channel-bound session must NOT auto-route (see header).
48
+ break;
49
+ case 'assistant/message': {
50
+ const streamedText = joinAssistantStreamText(event.data.stream);
51
+ const finalText = assistantText(event.data.message) || streamedText;
52
+ if (streamedText || finalText) {
53
+ const active = this.ensureActive(session, event.data.turn);
54
+ if (active) {
55
+ const existingText = active.buffer;
56
+ const remaining = streamedText.startsWith(existingText)
57
+ ? streamedText.slice(existingText.length)
58
+ : streamedText || finalText;
59
+ if (remaining)
60
+ this.appendText(active, remaining);
61
+ active.finalText = finalText;
62
+ }
63
+ }
64
+ break;
65
+ }
66
+ case 'turn/end':
67
+ void this.finishTurn(session, event.data.turn, event.data.reason);
68
+ break;
69
+ }
70
+ }
71
+ /** Session ids with an in-flight reply (used by the lifecycle drain). */
72
+ activeSessions() {
73
+ return [...this.active.keys()];
74
+ }
75
+ /**
76
+ * Finalize every reply still marked active, exactly like `turn/end` would.
77
+ * Turns still streaming at unload never deliver `turn/end`, so the lifecycle
78
+ * drain calls this after `whenIdle` to guarantee buffered/final text is
79
+ * delivered (or the reply handle fails) instead of being dropped.
80
+ */
81
+ async flushAll() {
82
+ for (const sessionId of [...this.active.keys()]) {
83
+ const active = this.active.get(sessionId);
84
+ if (active)
85
+ await this.finalize(active, active.turn);
86
+ }
87
+ }
88
+ /**
89
+ * Durable-log reconcile (doc §4.3 / §4.4): deliver the final assistant text
90
+ * for the last unfinished turn straight from the Session durable log, even
91
+ * when the `session/event` listener is no longer attached.
92
+ *
93
+ * Finds the last `turn/start` with no matching `turn/end`; if the
94
+ * ReplyRouter already holds an active reply for it, finalizes normally.
95
+ * Otherwise it rebuilds the assistant text from each V3
96
+ * `assistant/message.stream` (falling back to the message text) and delivers it via `adapter.send` (buffered
97
+ * strategy), then finishes/cleans up. The Session log is the ONLY source of
98
+ * transcript truth — reconcile never maintains a second copy.
99
+ */
100
+ async reconcileSession(session) {
101
+ const sessionId = String(session.id);
102
+ const unfinished = lastUnfinishedTurn(session.events);
103
+ if (!unfinished)
104
+ return; // no unfinished turn to reconcile
105
+ const { turn } = unfinished;
106
+ const rebuilt = rebuildAssistantText(session.events, turn);
107
+ // Same outbound gate as the live path: a durable-log turn with no active
108
+ // ReplyContext was not channel-inbound, so it must not be delivered back
109
+ // to the channel on unload — unless the binding opted into mirroring
110
+ // (issue #5), in which case the rebuilt final text is delivered buffered.
111
+ const context = this.options.replyContexts.getTurn(sessionId, turn);
112
+ if (!context) {
113
+ this.options.replyContexts.releaseTurn(sessionId, turn);
114
+ const mirrorBinding = rebuilt ? this.options.getBinding(sessionId) : undefined;
115
+ if (!mirrorBinding || mirrorBinding.mirror !== true || !rebuilt)
116
+ return;
117
+ const binding = mirrorBinding;
118
+ const adapter = this.options.getAdapter(binding.channelId);
119
+ if (!adapter || !adapter.capabilities.text)
120
+ return;
121
+ const mirrorContext = {
122
+ conversationType: binding.conversationType,
123
+ ...(binding.senderId === undefined ? {} : { senderId: binding.senderId }),
124
+ };
125
+ await this.deliver(adapter, targetFor(binding, mirrorContext), rebuilt);
126
+ return;
127
+ }
128
+ const active = this.active.get(sessionId);
129
+ if (active && !active.finished) {
130
+ // Already tracking this reply — finalize it normally with the durable
131
+ // text as the authoritative content.
132
+ active.buffer = rebuilt;
133
+ if (rebuilt)
134
+ active.finalText = rebuilt;
135
+ await this.finalize(active, turn);
136
+ return;
137
+ }
138
+ if (!rebuilt) {
139
+ this.options.replyContexts.releaseTurn(sessionId, turn);
140
+ return;
141
+ }
142
+ const binding = this.options.getBinding(sessionId);
143
+ if (!binding) {
144
+ this.options.logger.warn(`[channel-harness] reconcile: no session binding for '${sessionId}'`);
145
+ this.options.replyContexts.releaseTurn(sessionId, turn);
146
+ return;
147
+ }
148
+ const adapter = this.options.getAdapter(binding.channelId);
149
+ if (!adapter) {
150
+ this.options.logger.warn(`[channel-harness] reconcile: no adapter for channel '${binding.channelId}'`);
151
+ this.options.replyContexts.releaseTurn(sessionId, turn);
152
+ return;
153
+ }
154
+ if (active?.handle) {
155
+ // Deliver the missing remainder through the already-open reply handle.
156
+ await active.handle.finish(rebuilt ? { text: rebuilt } : undefined).catch(() => { });
157
+ }
158
+ else {
159
+ await this.deliver(adapter, targetFor(binding, context), rebuilt);
160
+ }
161
+ this.options.replyContexts.releaseTurn(sessionId, turn);
162
+ }
163
+ /** Clear all timers and drop active replies. */
164
+ dispose() {
165
+ for (const active of this.active.values()) {
166
+ if (active.timer)
167
+ clearTimeout(active.timer);
168
+ active.finished = true;
169
+ }
170
+ this.active.clear();
171
+ this.streamTurns.clear();
172
+ }
173
+ ensureActive(session, turn) {
174
+ const sessionId = String(session.id);
175
+ const context = this.options.replyContexts.getTurn(sessionId, turn);
176
+ if (!context) {
177
+ // ReplyContext is the outbound gate: a turn without an active context
178
+ // was not triggered by a channel inbound message, so it must never be
179
+ // routed back to the channel (even if the session is channel-bound) —
180
+ // unless the owner explicitly opted in via the binding's `mirror` flag
181
+ // (issue #5), in which case the turn is tracked as a buffered mirror.
182
+ return this.ensureMirrorActive(session, turn);
183
+ }
184
+ const existing = this.active.get(sessionId);
185
+ if (existing) {
186
+ // Defensive refresh: re-resolve target/strategy if the context changed.
187
+ // A session is single-flight (one turn at a time), so this is normally
188
+ // a no-op.
189
+ if (existing.context !== context) {
190
+ existing.context = context;
191
+ existing.target = targetFor(existing.binding, context);
192
+ existing.strategy = strategyFor(this.options.getAdapter(existing.binding.channelId), existing.target);
193
+ }
194
+ return existing;
195
+ }
196
+ const binding = this.options.getBinding(sessionId);
197
+ if (!binding) {
198
+ this.options.logger.warn(`[channel-harness] no session binding for '${sessionId}'`);
199
+ return null;
200
+ }
201
+ const adapter = this.options.getAdapter(binding.channelId);
202
+ if (!adapter) {
203
+ this.options.logger.warn(`[channel-harness] no adapter for channel '${binding.channelId}'`);
204
+ return null;
205
+ }
206
+ const target = targetFor(binding, context);
207
+ const active = {
208
+ binding,
209
+ strategy: strategyFor(adapter, target),
210
+ target,
211
+ context,
212
+ handle: null,
213
+ buffer: '',
214
+ finalText: '',
215
+ timer: null,
216
+ lastFlush: 0,
217
+ lastSentLength: 0,
218
+ turn,
219
+ finished: false,
220
+ flushing: null,
221
+ };
222
+ this.active.set(sessionId, active);
223
+ return active;
224
+ }
225
+ /**
226
+ * Mirror tracking for a non-channel turn (issue #5): only when the bound
227
+ * conversation opted in (`binding.mirror === true`) does the router attach
228
+ * a buffered-only reply that delivers the final assistant text to the bound
229
+ * conversation at `turn/end`. Everything else stays unrouted. The synthetic
230
+ * context carries NO platform state (no replyToMessageId / raw / runId) —
231
+ * the binding's stable identity is the sole delivery address.
232
+ */
233
+ ensureMirrorActive(session, turn) {
234
+ const sessionId = String(session.id);
235
+ const existing = this.active.get(sessionId);
236
+ if (existing)
237
+ return existing;
238
+ const binding = this.options.getBinding(sessionId);
239
+ if (!binding || binding.mirror !== true)
240
+ return null;
241
+ const adapter = this.options.getAdapter(binding.channelId);
242
+ if (!adapter || !adapter.capabilities.text)
243
+ return null;
244
+ const context = {
245
+ conversationType: binding.conversationType,
246
+ ...(binding.senderId === undefined ? {} : { senderId: binding.senderId }),
247
+ };
248
+ const active = {
249
+ binding,
250
+ // Mirror is always buffered: no edit streaming, no partial previews —
251
+ // only the final assistant text lands on the conversation.
252
+ strategy: 'buffered',
253
+ target: targetFor(binding, context),
254
+ context,
255
+ mirror: true,
256
+ handle: null,
257
+ buffer: '',
258
+ finalText: '',
259
+ timer: null,
260
+ lastFlush: 0,
261
+ lastSentLength: 0,
262
+ turn,
263
+ finished: false,
264
+ flushing: null,
265
+ };
266
+ this.active.set(sessionId, active);
267
+ return active;
268
+ }
269
+ appendText(active, delta) {
270
+ active.buffer += delta;
271
+ const interval = this.options.config.updateIntervalMs;
272
+ if (interval <= 0) {
273
+ // updateIntervalMs: 0 — flush on every delta (deterministic testing).
274
+ void this.flush(active);
275
+ return;
276
+ }
277
+ const now = Date.now();
278
+ if (now - active.lastFlush >= interval) {
279
+ void this.flush(active);
280
+ return;
281
+ }
282
+ if (!active.timer) {
283
+ active.timer = setTimeout(() => {
284
+ active.timer = null;
285
+ void this.flush(active);
286
+ }, interval - (now - active.lastFlush));
287
+ }
288
+ }
289
+ /**
290
+ * Enqueue a preview flush. Deltas are drained through a single in-flight
291
+ * chain so concurrent deltas can never double-`createReply` on the same
292
+ * reply; the chain reads the current buffer at execution time, so a flush
293
+ * that started before the newest delta still covers it.
294
+ */
295
+ flush(active) {
296
+ if (active.finished)
297
+ return;
298
+ if (!active.flushing) {
299
+ active.flushing = this.drain(active).finally(() => {
300
+ active.flushing = null;
301
+ });
302
+ }
303
+ }
304
+ /** Drain all pending deltas, flushing until the buffer is fully sent. */
305
+ async drain(active) {
306
+ while (!active.finished) {
307
+ if (active.strategy === 'buffered')
308
+ return; // send-once: deliver at turn/end
309
+ const sentBefore = active.lastSentLength;
310
+ await this.doFlush(active);
311
+ if (active.lastSentLength === sentBefore)
312
+ return; // no progress — stop
313
+ }
314
+ }
315
+ async doFlush(active) {
316
+ if (active.finished)
317
+ return;
318
+ active.lastFlush = Date.now();
319
+ if (active.buffer.length === active.lastSentLength)
320
+ return;
321
+ const adapter = this.options.getAdapter(active.binding.channelId);
322
+ if (!adapter?.createReply)
323
+ return;
324
+ try {
325
+ if (!active.handle) {
326
+ active.handle = await adapter.createReply(active.target, {
327
+ markdown: true,
328
+ });
329
+ // The owning scope may have finished while the card was created.
330
+ if (active.finished)
331
+ return;
332
+ }
333
+ if (active.strategy === 'edit') {
334
+ await active.handle.replace({ text: active.buffer });
335
+ active.lastSentLength = active.buffer.length;
336
+ }
337
+ else {
338
+ const delta = active.buffer.slice(active.lastSentLength);
339
+ active.lastSentLength = active.buffer.length;
340
+ await active.handle.append(delta);
341
+ }
342
+ }
343
+ catch (error) {
344
+ await active.handle?.fail(error).catch(() => { });
345
+ this.options.logger.error(`[channel-harness] reply flush failed for session '${active.binding.sessionId}'`, error);
346
+ }
347
+ }
348
+ resolveTurnTarget(sessionId, context) {
349
+ if (!context)
350
+ return undefined;
351
+ const binding = this.options.getBinding(sessionId);
352
+ if (!binding) {
353
+ this.options.logger.warn(`[channel-harness] no session binding for '${sessionId}'`);
354
+ return undefined;
355
+ }
356
+ const adapter = this.options.getAdapter(binding.channelId);
357
+ if (!adapter) {
358
+ this.options.logger.warn(`[channel-harness] no adapter for channel '${binding.channelId}'`);
359
+ return undefined;
360
+ }
361
+ return { binding, adapter, target: targetFor(binding, context) };
362
+ }
363
+ async finishTurn(session, turn, reason) {
364
+ const sessionId = String(session.id);
365
+ const active = this.active.get(sessionId);
366
+ // finalize() releases the reply context, so resolve it while it is still
367
+ // the outbound authority for this terminal event.
368
+ const context = this.options.replyContexts.getTurn(sessionId, turn);
369
+ const terminalTarget = active
370
+ ? (() => {
371
+ const adapter = this.options.getAdapter(active.binding.channelId);
372
+ return adapter
373
+ ? { binding: active.binding, adapter, target: active.target }
374
+ : undefined;
375
+ })()
376
+ : this.resolveTurnTarget(sessionId, context);
377
+ try {
378
+ // Existing assistant content retains its normal finalization path.
379
+ if (active)
380
+ await this.finalize(active, turn);
381
+ // A terminal failure is a separate system notice, never part of model
382
+ // output. The ReplyContext gate prevents Web/CLI turns from leaking to a
383
+ // channel-bound conversation.
384
+ if (reason.kind === 'error' && terminalTarget) {
385
+ this.options.logger.error(`[channel-harness] Harness turn failed for session '${sessionId}'`, {
386
+ turn,
387
+ code: reason.error.code,
388
+ status: reason.error.status,
389
+ requestId: reason.error.requestId,
390
+ providerRetryAfterMs: reason.error.providerRetryAfterMs,
391
+ });
392
+ try {
393
+ await this.deliver(terminalTarget.adapter, terminalTarget.target, formatChannelTurnFailure(reason.error));
394
+ }
395
+ catch (error) {
396
+ this.options.logger.error(`[channel-harness] failed to deliver turn error for session '${sessionId}'`, error);
397
+ }
398
+ }
399
+ }
400
+ finally {
401
+ // Active replies clean up in finalize(). No-output terminal turns need
402
+ // the same release and typing cleanup even when delivery fails.
403
+ if (!active) {
404
+ this.options.replyContexts.releaseTurn(sessionId, turn);
405
+ if (terminalTarget) {
406
+ this.stopTypingIfSupported(terminalTarget.binding.channelId, terminalTarget.target);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ /**
412
+ * Finalize one active reply: clear its timer, drain any in-flight preview
413
+ * chain, deliver the accumulated text (or fail the handle), and release the
414
+ * active reply context for its turn (v1.1 §20). Shared by `turn/end`
415
+ * handling and the lifecycle `flushAll()` so an unloaded turn is finalized
416
+ * exactly like a completed one.
417
+ */
418
+ async finalize(active, turn) {
419
+ if (active.timer) {
420
+ clearTimeout(active.timer);
421
+ active.timer = null;
422
+ }
423
+ this.active.delete(active.binding.sessionId);
424
+ if (active.finished)
425
+ return;
426
+ // Let any in-flight preview chain drain first; otherwise the flush could
427
+ // land an update on a card that is about to be finalized (orphan card).
428
+ if (active.flushing) {
429
+ await active.flushing.catch(() => { });
430
+ }
431
+ active.finished = true;
432
+ const text = active.buffer.length > 0 ? active.buffer : active.finalText;
433
+ const adapter = this.options.getAdapter(active.binding.channelId);
434
+ if (!adapter) {
435
+ this.options.logger.warn(`[channel-harness] no adapter for channel '${active.binding.channelId}'`);
436
+ // Still release the active context for this turn (v1.1 §20).
437
+ this.options.replyContexts.releaseTurn(active.binding.sessionId, turn);
438
+ return;
439
+ }
440
+ const target = active.target;
441
+ try {
442
+ if (active.handle) {
443
+ // Pass the authoritative accumulated text: for `edit`, deltas pending
444
+ // in the throttle window may not have been flushed to the handle yet,
445
+ // so finalizing without them would drop content (M2 regression).
446
+ await active.handle.finish(text ? { text } : undefined);
447
+ }
448
+ else if (active.strategy !== 'buffered' && adapter.createReply && text) {
449
+ const handle = await adapter.createReply(target, { markdown: true });
450
+ await handle.finish({ text });
451
+ }
452
+ else if (text) {
453
+ await this.deliver(adapter, target, text);
454
+ }
455
+ }
456
+ catch (error) {
457
+ try {
458
+ await active.handle?.fail(error);
459
+ }
460
+ catch {
461
+ // Ignore secondary failure during fail.
462
+ }
463
+ this.options.logger.error(`[channel-harness] reply finish failed for session '${active.binding.sessionId}'`, error);
464
+ }
465
+ // Release the active reply context for this turn (v1.1 §20).
466
+ this.options.replyContexts.releaseTurn(active.binding.sessionId, turn);
467
+ // Best-effort typing stop: a typing API failure must never break delivery.
468
+ this.stopTypingIfSupported(active.binding.channelId, active.target);
469
+ }
470
+ stopTypingIfSupported(channelId, target) {
471
+ const adapter = this.options.getAdapter(channelId);
472
+ if (adapter?.stopTypingForTarget) {
473
+ void adapter.stopTypingForTarget(target).catch(() => { });
474
+ }
475
+ else if (adapter?.stopTyping) {
476
+ void adapter.stopTyping(target.conversationId).catch(() => { });
477
+ }
478
+ }
479
+ async deliver(adapter, target, text) {
480
+ if (!text)
481
+ return;
482
+ const max = this.options.config.maxTextLength;
483
+ if (!max || text.length <= max) {
484
+ await adapter.send(target, { text });
485
+ return;
486
+ }
487
+ const pieces = splitMessage(text, max, this.options.config);
488
+ for (const piece of pieces) {
489
+ await adapter.send(target, { text: piece });
490
+ }
491
+ }
492
+ }
493
+ function strategyFor(adapter, target) {
494
+ const mode = adapter.resolveStreamingMode?.(target) ?? adapter.capabilities.streaming;
495
+ if (adapter.createReply) {
496
+ if (mode === 'native')
497
+ return 'native';
498
+ if (mode === 'edit')
499
+ return 'edit';
500
+ }
501
+ return 'buffered';
502
+ }
503
+ function assistantText(message) {
504
+ let text = '';
505
+ for (const block of message.content) {
506
+ if (block.type === 'text')
507
+ text += block.text;
508
+ }
509
+ return text;
510
+ }
511
+ function targetFor(binding, context) {
512
+ const target = {
513
+ channelId: binding.channelId,
514
+ accountId: binding.accountId,
515
+ conversationId: binding.conversationId,
516
+ };
517
+ if (binding.threadId) {
518
+ target.threadId = binding.threadId;
519
+ }
520
+ if (context) {
521
+ target.conversationType = context.conversationType;
522
+ if (context.replyToMessageId) {
523
+ target.replyToMessageId = context.replyToMessageId;
524
+ }
525
+ if (context.raw !== undefined) {
526
+ target.raw = context.raw;
527
+ }
528
+ if (context.runId) {
529
+ target.runId = context.runId;
530
+ }
531
+ }
532
+ return target;
533
+ }
534
+ /**
535
+ * Find the last turn that started but never ended. Returns its turn number, or
536
+ * `undefined` when every opened turn was closed.
537
+ */
538
+ function lastUnfinishedTurn(events) {
539
+ let lastOpen;
540
+ for (const event of events) {
541
+ if (event.type === 'turn/start') {
542
+ lastOpen = event.data.turn;
543
+ }
544
+ else if (event.type === 'turn/end' && event.data.turn === lastOpen) {
545
+ lastOpen = undefined;
546
+ }
547
+ }
548
+ return lastOpen === undefined ? undefined : { turn: lastOpen };
549
+ }
550
+ /**
551
+ * Rebuild the assistant text for one turn from its V3 compact assistant
552
+ * streams, falling back to `assistant/message` text when no delta flowed.
553
+ */
554
+ function rebuildAssistantText(events, turn) {
555
+ let text = '';
556
+ for (const event of events) {
557
+ if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && event.data.turn === turn) {
558
+ text += joinAssistantStreamText(event.data.stream);
559
+ }
560
+ }
561
+ if (text.length > 0)
562
+ return text;
563
+ for (const event of events) {
564
+ if (event.type === 'assistant/message' && event.data.turn === turn) {
565
+ const fallback = assistantText(event.data.message);
566
+ if (fallback)
567
+ return fallback;
568
+ }
569
+ }
570
+ return '';
571
+ }
572
+ /**
573
+ * Split a long message into `maxLength`-bounded pieces, keeping fenced code
574
+ * blocks whole and preferring paragraph boundaries. `finalFlush: false`
575
+ * drops a trailing partial piece instead of delivering it.
576
+ */
577
+ export function splitMessage(text, maxLength, config) {
578
+ if (text.length <= maxLength)
579
+ return [text];
580
+ const segments = config.splitCodeBlocks ? splitCodeSegments(text) : [text];
581
+ const pieces = [];
582
+ for (const segment of segments) {
583
+ if (segment.length === 0)
584
+ continue;
585
+ if (segment.length <= maxLength) {
586
+ pieces.push(segment);
587
+ continue;
588
+ }
589
+ if (config.splitParagraphs) {
590
+ pieces.push(...packSegments(segment, maxLength));
591
+ }
592
+ else {
593
+ pieces.push(...hardWrap(segment, maxLength));
594
+ }
595
+ }
596
+ if (!config.finalFlush && pieces.length > 1) {
597
+ const last = pieces[pieces.length - 1];
598
+ if (last && last.length < maxLength)
599
+ pieces.pop();
600
+ }
601
+ return pieces;
602
+ }
603
+ function splitCodeSegments(text) {
604
+ const segments = [];
605
+ const fence = /```[\s\S]*?```/g;
606
+ let cursor = 0;
607
+ let match;
608
+ while ((match = fence.exec(text))) {
609
+ const before = text.slice(cursor, match.index);
610
+ if (before)
611
+ segments.push(before);
612
+ segments.push(match[0]);
613
+ cursor = match.index + match[0].length;
614
+ }
615
+ const rest = text.slice(cursor);
616
+ if (rest)
617
+ segments.push(rest);
618
+ return segments.length > 0 ? segments : [text];
619
+ }
620
+ function packSegments(segment, maxLength) {
621
+ const paragraphs = segment.split(/\n\s*\n/);
622
+ const pieces = [];
623
+ let current = '';
624
+ for (const paragraph of paragraphs) {
625
+ if (paragraph.length <= maxLength) {
626
+ const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
627
+ if (candidate.length <= maxLength) {
628
+ current = candidate;
629
+ }
630
+ else {
631
+ if (current)
632
+ pieces.push(current);
633
+ current = paragraph;
634
+ }
635
+ }
636
+ else {
637
+ if (current)
638
+ pieces.push(current);
639
+ current = '';
640
+ pieces.push(...hardWrap(paragraph, maxLength));
641
+ }
642
+ }
643
+ if (current)
644
+ pieces.push(current);
645
+ return pieces;
646
+ }
647
+ function hardWrap(text, maxLength) {
648
+ const pieces = [];
649
+ let current = '';
650
+ for (const line of text.split('\n')) {
651
+ if (line.length > maxLength) {
652
+ if (current)
653
+ pieces.push(current);
654
+ current = '';
655
+ for (let i = 0; i < line.length; i += maxLength) {
656
+ pieces.push(line.slice(i, i + maxLength));
657
+ }
658
+ }
659
+ else {
660
+ const candidate = current ? `${current}\n${line}` : line;
661
+ if (candidate.length <= maxLength) {
662
+ current = candidate;
663
+ }
664
+ else {
665
+ pieces.push(current);
666
+ current = line;
667
+ }
668
+ }
669
+ }
670
+ if (current)
671
+ pieces.push(current);
672
+ return pieces;
673
+ }
674
+ //# sourceMappingURL=reply-router.js.map