@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.
- package/README.md +110 -0
- package/lib/access/controller.d.ts +37 -0
- package/lib/access/controller.d.ts.map +1 -0
- package/lib/access/controller.js +51 -0
- package/lib/access/controller.js.map +1 -0
- package/lib/access/decision.d.ts +18 -0
- package/lib/access/decision.d.ts.map +1 -0
- package/lib/access/decision.js +2 -0
- package/lib/access/decision.js.map +1 -0
- package/lib/access/resolver.d.ts +41 -0
- package/lib/access/resolver.d.ts.map +1 -0
- package/lib/access/resolver.js +31 -0
- package/lib/access/resolver.js.map +1 -0
- package/lib/agent-manager.d.ts +314 -0
- package/lib/agent-manager.d.ts.map +1 -0
- package/lib/agent-manager.js +603 -0
- package/lib/agent-manager.js.map +1 -0
- package/lib/agent-router.d.ts +37 -0
- package/lib/agent-router.d.ts.map +1 -0
- package/lib/agent-router.js +33 -0
- package/lib/agent-router.js.map +1 -0
- package/lib/bind-support.d.ts +23 -0
- package/lib/bind-support.d.ts.map +1 -0
- package/lib/bind-support.js +63 -0
- package/lib/bind-support.js.map +1 -0
- package/lib/binding-store.d.ts +104 -0
- package/lib/binding-store.d.ts.map +1 -0
- package/lib/binding-store.js +354 -0
- package/lib/binding-store.js.map +1 -0
- package/lib/bridge.d.ts +248 -0
- package/lib/bridge.d.ts.map +1 -0
- package/lib/bridge.js +961 -0
- package/lib/bridge.js.map +1 -0
- package/lib/channel-label.d.ts +46 -0
- package/lib/channel-label.d.ts.map +1 -0
- package/lib/channel-label.js +89 -0
- package/lib/channel-label.js.map +1 -0
- package/lib/channel-session-factory.d.ts +76 -0
- package/lib/channel-session-factory.d.ts.map +1 -0
- package/lib/channel-session-factory.js +187 -0
- package/lib/channel-session-factory.js.map +1 -0
- package/lib/commands/bind.d.ts +21 -0
- package/lib/commands/bind.d.ts.map +1 -0
- package/lib/commands/bind.js +91 -0
- package/lib/commands/bind.js.map +1 -0
- package/lib/commands/help.d.ts +15 -0
- package/lib/commands/help.d.ts.map +1 -0
- package/lib/commands/help.js +89 -0
- package/lib/commands/help.js.map +1 -0
- package/lib/commands/index.d.ts +122 -0
- package/lib/commands/index.d.ts.map +1 -0
- package/lib/commands/index.js +63 -0
- package/lib/commands/index.js.map +1 -0
- package/lib/commands/locale.d.ts +9 -0
- package/lib/commands/locale.d.ts.map +1 -0
- package/lib/commands/locale.js +27 -0
- package/lib/commands/locale.js.map +1 -0
- package/lib/commands/mirror.d.ts +14 -0
- package/lib/commands/mirror.d.ts.map +1 -0
- package/lib/commands/mirror.js +44 -0
- package/lib/commands/mirror.js.map +1 -0
- package/lib/commands/model.d.ts +20 -0
- package/lib/commands/model.d.ts.map +1 -0
- package/lib/commands/model.js +106 -0
- package/lib/commands/model.js.map +1 -0
- package/lib/commands/models.d.ts +14 -0
- package/lib/commands/models.d.ts.map +1 -0
- package/lib/commands/models.js +67 -0
- package/lib/commands/models.js.map +1 -0
- package/lib/commands/new.d.ts +14 -0
- package/lib/commands/new.d.ts.map +1 -0
- package/lib/commands/new.js +37 -0
- package/lib/commands/new.js.map +1 -0
- package/lib/commands/status.d.ts +12 -0
- package/lib/commands/status.d.ts.map +1 -0
- package/lib/commands/status.js +30 -0
- package/lib/commands/status.js.map +1 -0
- package/lib/commands/stop.d.ts +16 -0
- package/lib/commands/stop.d.ts.map +1 -0
- package/lib/commands/stop.js +27 -0
- package/lib/commands/stop.js.map +1 -0
- package/lib/commands/version.d.ts +25 -0
- package/lib/commands/version.d.ts.map +1 -0
- package/lib/commands/version.js +57 -0
- package/lib/commands/version.js.map +1 -0
- package/lib/config.d.ts +119 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +60 -0
- package/lib/config.js.map +1 -0
- package/lib/debug-logger.d.ts +8 -0
- package/lib/debug-logger.d.ts.map +1 -0
- package/lib/debug-logger.js +32 -0
- package/lib/debug-logger.js.map +1 -0
- package/lib/dsh-home.d.ts +12 -0
- package/lib/dsh-home.d.ts.map +1 -0
- package/lib/dsh-home.js +31 -0
- package/lib/dsh-home.js.map +1 -0
- package/lib/failure-display.d.ts +10 -0
- package/lib/failure-display.d.ts.map +1 -0
- package/lib/failure-display.js +42 -0
- package/lib/failure-display.js.map +1 -0
- package/lib/file-provider.d.ts +96 -0
- package/lib/file-provider.d.ts.map +1 -0
- package/lib/file-provider.js +44 -0
- package/lib/file-provider.js.map +1 -0
- package/lib/index.d.ts +31 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +31 -0
- package/lib/index.js.map +1 -0
- package/lib/interactions/index.d.ts +13 -0
- package/lib/interactions/index.d.ts.map +1 -0
- package/lib/interactions/index.js +13 -0
- package/lib/interactions/index.js.map +1 -0
- package/lib/interactions/question-backend.d.ts +133 -0
- package/lib/interactions/question-backend.d.ts.map +1 -0
- package/lib/interactions/question-backend.js +37 -0
- package/lib/interactions/question-backend.js.map +1 -0
- package/lib/interactions/question-presenter.d.ts +68 -0
- package/lib/interactions/question-presenter.d.ts.map +1 -0
- package/lib/interactions/question-presenter.js +425 -0
- package/lib/interactions/question-presenter.js.map +1 -0
- package/lib/interactions/question-renderer.d.ts +37 -0
- package/lib/interactions/question-renderer.d.ts.map +1 -0
- package/lib/interactions/question-renderer.js +79 -0
- package/lib/interactions/question-renderer.js.map +1 -0
- package/lib/interactions/question-state.d.ts +106 -0
- package/lib/interactions/question-state.d.ts.map +1 -0
- package/lib/interactions/question-state.js +99 -0
- package/lib/interactions/question-state.js.map +1 -0
- package/lib/interactions/question-text-answer.d.ts +37 -0
- package/lib/interactions/question-text-answer.d.ts.map +1 -0
- package/lib/interactions/question-text-answer.js +70 -0
- package/lib/interactions/question-text-answer.js.map +1 -0
- package/lib/interactions/question-waterfall-backend.d.ts +42 -0
- package/lib/interactions/question-waterfall-backend.d.ts.map +1 -0
- package/lib/interactions/question-waterfall-backend.js +162 -0
- package/lib/interactions/question-waterfall-backend.js.map +1 -0
- package/lib/lifecycle.d.ts +36 -0
- package/lib/lifecycle.d.ts.map +1 -0
- package/lib/lifecycle.js +303 -0
- package/lib/lifecycle.js.map +1 -0
- package/lib/loggable-error.d.ts +10 -0
- package/lib/loggable-error.d.ts.map +1 -0
- package/lib/loggable-error.js +32 -0
- package/lib/loggable-error.js.map +1 -0
- package/lib/message-converter.d.ts +64 -0
- package/lib/message-converter.d.ts.map +1 -0
- package/lib/message-converter.js +191 -0
- package/lib/message-converter.js.map +1 -0
- package/lib/model-selection.d.ts +54 -0
- package/lib/model-selection.d.ts.map +1 -0
- package/lib/model-selection.js +116 -0
- package/lib/model-selection.js.map +1 -0
- package/lib/outbox/binding-resolver.d.ts +23 -0
- package/lib/outbox/binding-resolver.d.ts.map +1 -0
- package/lib/outbox/binding-resolver.js +38 -0
- package/lib/outbox/binding-resolver.js.map +1 -0
- package/lib/outbox/capabilities.d.ts +48 -0
- package/lib/outbox/capabilities.d.ts.map +1 -0
- package/lib/outbox/capabilities.js +35 -0
- package/lib/outbox/capabilities.js.map +1 -0
- package/lib/outbox/index.d.ts +14 -0
- package/lib/outbox/index.d.ts.map +1 -0
- package/lib/outbox/index.js +14 -0
- package/lib/outbox/index.js.map +1 -0
- package/lib/outbox/service.d.ts +48 -0
- package/lib/outbox/service.d.ts.map +1 -0
- package/lib/outbox/service.js +74 -0
- package/lib/outbox/service.js.map +1 -0
- package/lib/outbox/target.d.ts +17 -0
- package/lib/outbox/target.d.ts.map +1 -0
- package/lib/outbox/target.js +15 -0
- package/lib/outbox/target.js.map +1 -0
- package/lib/outbox/tool-send.d.ts +19 -0
- package/lib/outbox/tool-send.d.ts.map +1 -0
- package/lib/outbox/tool-send.js +77 -0
- package/lib/outbox/tool-send.js.map +1 -0
- package/lib/outbox/types.d.ts +42 -0
- package/lib/outbox/types.d.ts.map +1 -0
- package/lib/outbox/types.js +30 -0
- package/lib/outbox/types.js.map +1 -0
- package/lib/plugin.d.ts +27 -0
- package/lib/plugin.d.ts.map +1 -0
- package/lib/plugin.js +47 -0
- package/lib/plugin.js.map +1 -0
- package/lib/reply-context-store.d.ts +69 -0
- package/lib/reply-context-store.d.ts.map +1 -0
- package/lib/reply-context-store.js +75 -0
- package/lib/reply-context-store.js.map +1 -0
- package/lib/reply-router.d.ts +138 -0
- package/lib/reply-router.d.ts.map +1 -0
- package/lib/reply-router.js +674 -0
- package/lib/reply-router.js.map +1 -0
- package/lib/session-router.d.ts +49 -0
- package/lib/session-router.d.ts.map +1 -0
- package/lib/session-router.js +45 -0
- package/lib/session-router.js.map +1 -0
- package/lib/workspace-resolver.d.ts +67 -0
- package/lib/workspace-resolver.d.ts.map +1 -0
- package/lib/workspace-resolver.js +102 -0
- package/lib/workspace-resolver.js.map +1 -0
- 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
|