@qwen-code/channel-base 0.21.1 → 0.21.2-nightly.20260801.bc382c3ff
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 +3 -1
- package/dist/AcpBridge.d.ts +1 -0
- package/dist/AcpBridge.d.ts.map +1 -1
- package/dist/AcpBridge.js +26 -5
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelBase.d.ts +41 -4
- package/dist/ChannelBase.d.ts.map +1 -1
- package/dist/ChannelBase.js +364 -14
- package/dist/ChannelBase.js.map +1 -1
- package/dist/ChannelLoopScheduler.d.ts +7 -0
- package/dist/ChannelLoopScheduler.d.ts.map +1 -1
- package/dist/ChannelLoopScheduler.js +25 -0
- package/dist/ChannelLoopScheduler.js.map +1 -1
- package/dist/PairingStore.d.ts +3 -2
- package/dist/PairingStore.d.ts.map +1 -1
- package/dist/PairingStore.js +11 -2
- package/dist/PairingStore.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename, join } from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { BlockStreamer } from './BlockStreamer.js';
|
|
3
4
|
import { ChannelProactiveDeliveryError, isChannelProactiveDeliveryError, } from './ChannelProactiveDeliveryError.js';
|
|
4
5
|
import { GroupGate } from './GroupGate.js';
|
|
@@ -62,6 +63,9 @@ const SENSITIVE_PAYLOAD_KEY_PATTERN = new RegExp([
|
|
|
62
63
|
'senderNick',
|
|
63
64
|
'senderName',
|
|
64
65
|
].join('|'), 'i');
|
|
66
|
+
function isRecord(value) {
|
|
67
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
68
|
+
}
|
|
65
69
|
/**
|
|
66
70
|
* Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
|
|
67
71
|
* plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
|
|
@@ -89,6 +93,10 @@ function isUnattendedWebhookApprovalMode(mode) {
|
|
|
89
93
|
}
|
|
90
94
|
export class ChannelBase {
|
|
91
95
|
config;
|
|
96
|
+
/**
|
|
97
|
+
* Recovery invariant: session-resolution and prompt-capture paths must await
|
|
98
|
+
* waitForBridgeRecovery() immediately before that operation.
|
|
99
|
+
*/
|
|
92
100
|
bridge;
|
|
93
101
|
groupGate;
|
|
94
102
|
dmGate;
|
|
@@ -100,6 +108,8 @@ export class ChannelBase {
|
|
|
100
108
|
memoryScope;
|
|
101
109
|
/** Resolved proxy URL, available to subclasses for adapter-specific clients. */
|
|
102
110
|
proxy;
|
|
111
|
+
/** Adapter-owned persistent state directory, when supplied by the runtime. */
|
|
112
|
+
stateDir;
|
|
103
113
|
channelMemory;
|
|
104
114
|
memoryIntentClassifier;
|
|
105
115
|
channelMemoryRecallObserver;
|
|
@@ -115,6 +125,7 @@ export class ChannelBase {
|
|
|
115
125
|
/** Per-session promise chain to serialize prompt + send (followup mode). */
|
|
116
126
|
sessionQueues = new Map();
|
|
117
127
|
registerBridgeEvents;
|
|
128
|
+
bridgeRecovery;
|
|
118
129
|
/**
|
|
119
130
|
* Per-session generation, bumped by /clear. A queued followup turn captures the
|
|
120
131
|
* generation when it enqueues and bails if /clear bumped it before the turn ran,
|
|
@@ -212,6 +223,7 @@ export class ChannelBase {
|
|
|
212
223
|
sessionId: event.sessionId,
|
|
213
224
|
target,
|
|
214
225
|
request: event.request,
|
|
226
|
+
settlementListeners: new Set(),
|
|
215
227
|
};
|
|
216
228
|
this.pendingPermissions.set(event.requestId, pending);
|
|
217
229
|
const chatKey = this.permissionChatKey(target);
|
|
@@ -219,6 +231,10 @@ export class ChannelBase {
|
|
|
219
231
|
requestIds.push(event.requestId);
|
|
220
232
|
this.pendingPermissionsByChat.set(chatKey, requestIds);
|
|
221
233
|
try {
|
|
234
|
+
const presentation = this.tryPresentUserInput(pending);
|
|
235
|
+
if (presentation && (await presentation)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
222
238
|
const text = this.formatPermissionRequest(pending);
|
|
223
239
|
if (target.threadId !== undefined &&
|
|
224
240
|
this.supportsProactiveSend() &&
|
|
@@ -230,7 +246,7 @@ export class ChannelBase {
|
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
catch (err) {
|
|
233
|
-
this.removePendingPermission(event.requestId);
|
|
249
|
+
this.removePendingPermission(event.requestId, 'cancelled');
|
|
234
250
|
try {
|
|
235
251
|
await this.bridge.respondToPermission?.(event.requestId, {
|
|
236
252
|
outcome: { outcome: 'cancelled' },
|
|
@@ -242,6 +258,154 @@ export class ChannelBase {
|
|
|
242
258
|
throw err;
|
|
243
259
|
}
|
|
244
260
|
}
|
|
261
|
+
tryPresentUserInput(pending) {
|
|
262
|
+
const active = this.activePrompts.get(pending.sessionId);
|
|
263
|
+
const questions = this.normalizeUserQuestions(pending);
|
|
264
|
+
const submitOptionId = this.approvalOptionId(pending);
|
|
265
|
+
if (!active ||
|
|
266
|
+
active.loopPrompt ||
|
|
267
|
+
!active.owner ||
|
|
268
|
+
!questions ||
|
|
269
|
+
!submitOptionId) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const precedingSegment = this.closeOutputSegment(pending.sessionId, active, pending.target);
|
|
273
|
+
let respondInvoked = false;
|
|
274
|
+
const context = {
|
|
275
|
+
requestId: pending.requestId,
|
|
276
|
+
sessionId: pending.sessionId,
|
|
277
|
+
runId: active.runId,
|
|
278
|
+
owner: active.owner,
|
|
279
|
+
target: pending.target,
|
|
280
|
+
...(precedingSegment
|
|
281
|
+
? { precedingSegmentId: precedingSegment.segmentId }
|
|
282
|
+
: {}),
|
|
283
|
+
questions,
|
|
284
|
+
submitOptionId,
|
|
285
|
+
onSettled: (listener) => {
|
|
286
|
+
if (pending.settled) {
|
|
287
|
+
listener(pending.settled);
|
|
288
|
+
return () => { };
|
|
289
|
+
}
|
|
290
|
+
pending.settlementListeners.add(listener);
|
|
291
|
+
return () => {
|
|
292
|
+
pending.settlementListeners.delete(listener);
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
respond: (response) => {
|
|
296
|
+
respondInvoked = true;
|
|
297
|
+
return this.respondToUserInput(pending, response);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
pending.userInputPresented = true;
|
|
301
|
+
return (async () => {
|
|
302
|
+
try {
|
|
303
|
+
if (precedingSegment) {
|
|
304
|
+
await this.notifyOutputSegmentEnd(pending.target.chatId, pending.sessionId, precedingSegment, 'input_requested');
|
|
305
|
+
}
|
|
306
|
+
const result = await this.presentUserInputRequest(context);
|
|
307
|
+
if (this.pendingPermissions.get(pending.requestId) !== pending) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (result.kind === 'presented' ||
|
|
311
|
+
(result.kind === 'handled' && respondInvoked)) {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
pending.userInputPresented = false;
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
process.stderr.write(`[${this.name}] user input presentation failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
|
|
319
|
+
if (this.pendingPermissions.get(pending.requestId) !== pending) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
pending.userInputPresented = false;
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
})();
|
|
326
|
+
}
|
|
327
|
+
normalizeUserQuestions(pending) {
|
|
328
|
+
const toolCall = pending.request.toolCall;
|
|
329
|
+
const meta = isRecord(toolCall['_meta']) ? toolCall['_meta'] : undefined;
|
|
330
|
+
const canonical = meta?.['qwenInteractionKind'] === 'user_question';
|
|
331
|
+
const identifiedLegacy = meta?.['toolName'] === 'ask_user_question' ||
|
|
332
|
+
toolCall['kind'] === 'ask_user_question';
|
|
333
|
+
const rawInput = isRecord(toolCall['rawInput'])
|
|
334
|
+
? toolCall['rawInput']
|
|
335
|
+
: undefined;
|
|
336
|
+
const rawQuestions = canonical
|
|
337
|
+
? meta?.['qwenQuestions']
|
|
338
|
+
: identifiedLegacy
|
|
339
|
+
? rawInput?.['questions']
|
|
340
|
+
: undefined;
|
|
341
|
+
if (!Array.isArray(rawQuestions) ||
|
|
342
|
+
rawQuestions.length < 1 ||
|
|
343
|
+
rawQuestions.length > 4) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
const questions = [];
|
|
347
|
+
for (const [index, rawQuestion] of rawQuestions.entries()) {
|
|
348
|
+
if (!isRecord(rawQuestion)) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const header = rawQuestion['header'];
|
|
352
|
+
const question = rawQuestion['question'];
|
|
353
|
+
const rawOptions = rawQuestion['options'];
|
|
354
|
+
const multiSelect = rawQuestion['multiSelect'];
|
|
355
|
+
if (typeof header !== 'string' ||
|
|
356
|
+
header.trim().length === 0 ||
|
|
357
|
+
typeof question !== 'string' ||
|
|
358
|
+
question.trim().length === 0 ||
|
|
359
|
+
!Array.isArray(rawOptions) ||
|
|
360
|
+
rawOptions.length < 2 ||
|
|
361
|
+
rawOptions.length > 4 ||
|
|
362
|
+
(multiSelect !== undefined && typeof multiSelect !== 'boolean')) {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
const options = [];
|
|
366
|
+
for (const rawOption of rawOptions) {
|
|
367
|
+
if (!isRecord(rawOption) ||
|
|
368
|
+
typeof rawOption['label'] !== 'string' ||
|
|
369
|
+
rawOption['label'].trim().length === 0 ||
|
|
370
|
+
typeof rawOption['description'] !== 'string') {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
options.push({
|
|
374
|
+
label: rawOption['label'],
|
|
375
|
+
description: rawOption['description'],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
questions.push({
|
|
379
|
+
answerKey: String(index),
|
|
380
|
+
header,
|
|
381
|
+
question,
|
|
382
|
+
options,
|
|
383
|
+
multiSelect: multiSelect ?? false,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return questions;
|
|
387
|
+
}
|
|
388
|
+
async respondToUserInput(pending, response) {
|
|
389
|
+
if (pending.responsePromise) {
|
|
390
|
+
return pending.responsePromise;
|
|
391
|
+
}
|
|
392
|
+
if (this.pendingPermissions.get(pending.requestId) !== pending ||
|
|
393
|
+
!this.bridge.respondToPermission) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
pending.responsePromise = Promise.resolve()
|
|
397
|
+
.then(() => this.bridge.respondToPermission(pending.requestId, response))
|
|
398
|
+
.then((accepted) => {
|
|
399
|
+
this.removePendingPermission(pending.requestId, accepted
|
|
400
|
+
? this.userInputSettlementReason(pending, response.outcome)
|
|
401
|
+
: 'cancelled');
|
|
402
|
+
return accepted;
|
|
403
|
+
}, (error) => {
|
|
404
|
+
this.removePendingPermission(pending.requestId, 'cancelled');
|
|
405
|
+
throw error;
|
|
406
|
+
});
|
|
407
|
+
return pending.responsePromise;
|
|
408
|
+
}
|
|
245
409
|
permissionTargetForEvent(event) {
|
|
246
410
|
const routeTarget = this.router.getTarget(event.sessionId);
|
|
247
411
|
if (!routeTarget || routeTarget.channelName !== this.name) {
|
|
@@ -268,13 +432,18 @@ export class ChannelBase {
|
|
|
268
432
|
return target;
|
|
269
433
|
}
|
|
270
434
|
dispatchPermissionResolved(event) {
|
|
271
|
-
this.
|
|
435
|
+
const pending = this.pendingPermissions.get(event.requestId);
|
|
436
|
+
if (!pending) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.removePendingPermission(event.requestId, this.userInputSettlementReason(pending, event.outcome));
|
|
272
440
|
}
|
|
273
441
|
constructor(name, config, bridge, options) {
|
|
274
442
|
this.name = name;
|
|
275
443
|
this.config = config;
|
|
276
444
|
this.bridge = bridge;
|
|
277
445
|
this.proxy = options?.proxy;
|
|
446
|
+
this.stateDir = options?.stateDir;
|
|
278
447
|
this.identity = Object.freeze(this.resolveIdentity(name, config));
|
|
279
448
|
this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
|
|
280
449
|
this.channelMemory = options?.channelMemory;
|
|
@@ -284,6 +453,7 @@ export class ChannelBase {
|
|
|
284
453
|
join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
|
|
285
454
|
this.loopController = options?.loopController;
|
|
286
455
|
this.observedContacts = options?.observedContacts;
|
|
456
|
+
this.bridgeRecovery = options?.bridgeRecovery;
|
|
287
457
|
this.groupGate = new GroupGate(config.groupPolicy, config.groups);
|
|
288
458
|
this.dmGate = new DmGate(config.dmPolicy);
|
|
289
459
|
// Scoped by the channel's workspace cwd: two workspaces reusing the same
|
|
@@ -322,6 +492,9 @@ export class ChannelBase {
|
|
|
322
492
|
* logged, nothing more.
|
|
323
493
|
*/
|
|
324
494
|
onTaskLifecycle(_event) { }
|
|
495
|
+
async presentUserInputRequest(_context) {
|
|
496
|
+
return { kind: 'unsupported' };
|
|
497
|
+
}
|
|
325
498
|
emitTaskLifecycle(event) {
|
|
326
499
|
try {
|
|
327
500
|
const result = this.onTaskLifecycle(event);
|
|
@@ -351,6 +524,8 @@ export class ChannelBase {
|
|
|
351
524
|
return;
|
|
352
525
|
}
|
|
353
526
|
active.cancellationEmitted = true;
|
|
527
|
+
const segment = this.closeOutputSegment(sessionId, active);
|
|
528
|
+
void this.notifyOutputSegmentEnd(active.chatId, sessionId, segment, 'cancelled');
|
|
354
529
|
this.emitTaskLifecycle({
|
|
355
530
|
...this.lifecycleBase(active.chatId, sessionId, active.messageId),
|
|
356
531
|
type: 'cancelled',
|
|
@@ -427,15 +602,72 @@ export class ChannelBase {
|
|
|
427
602
|
return Boolean(this.config.identity || this.config.memoryScope);
|
|
428
603
|
}
|
|
429
604
|
lifecycleBase(chatId, sessionId, messageId) {
|
|
605
|
+
const active = this.activePrompts.get(sessionId);
|
|
430
606
|
return {
|
|
431
607
|
channelName: this.name,
|
|
432
608
|
chatId,
|
|
433
609
|
sessionId,
|
|
434
610
|
...(messageId ? { messageId } : {}),
|
|
611
|
+
...(active?.runId ? { runId: active.runId } : {}),
|
|
612
|
+
...(active?.owner ? { owner: active.owner } : {}),
|
|
435
613
|
identity: this.identity,
|
|
436
614
|
memoryScope: this.memoryScope,
|
|
437
615
|
};
|
|
438
616
|
}
|
|
617
|
+
outputSegmentContext(sessionId, active, segmentId, target) {
|
|
618
|
+
const resolvedTarget = target ??
|
|
619
|
+
(active.senderId
|
|
620
|
+
? {
|
|
621
|
+
channelName: this.name,
|
|
622
|
+
chatId: active.chatId,
|
|
623
|
+
senderId: active.senderId,
|
|
624
|
+
...(active.threadId ? { threadId: active.threadId } : {}),
|
|
625
|
+
...(active.isGroup !== undefined
|
|
626
|
+
? { isGroup: active.isGroup }
|
|
627
|
+
: {}),
|
|
628
|
+
}
|
|
629
|
+
: undefined);
|
|
630
|
+
if (!active.owner ||
|
|
631
|
+
!resolvedTarget ||
|
|
632
|
+
resolvedTarget.channelName !== this.name) {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
channelName: this.name,
|
|
637
|
+
sessionId,
|
|
638
|
+
runId: active.runId,
|
|
639
|
+
segmentId,
|
|
640
|
+
owner: active.owner,
|
|
641
|
+
target: resolvedTarget,
|
|
642
|
+
...(active.messageId ? { messageId: active.messageId } : {}),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
ensureOutputSegment(sessionId, active) {
|
|
646
|
+
if (!active.owner)
|
|
647
|
+
return undefined;
|
|
648
|
+
const segmentId = active.activeSegmentId ?? randomUUID();
|
|
649
|
+
const context = this.outputSegmentContext(sessionId, active, segmentId);
|
|
650
|
+
if (context)
|
|
651
|
+
active.activeSegmentId = segmentId;
|
|
652
|
+
return context;
|
|
653
|
+
}
|
|
654
|
+
closeOutputSegment(sessionId, active, target) {
|
|
655
|
+
const segmentId = active.activeSegmentId;
|
|
656
|
+
if (!segmentId)
|
|
657
|
+
return undefined;
|
|
658
|
+
active.activeSegmentId = undefined;
|
|
659
|
+
return this.outputSegmentContext(sessionId, active, segmentId, target);
|
|
660
|
+
}
|
|
661
|
+
async notifyOutputSegmentEnd(chatId, sessionId, segment, reason) {
|
|
662
|
+
if (!segment)
|
|
663
|
+
return;
|
|
664
|
+
try {
|
|
665
|
+
await this.onOutputSegmentEnd(chatId, sessionId, segment, reason);
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
process.stderr.write(`[${this.name}] output segment boundary failed for session ${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
439
671
|
supportsProactiveSend() {
|
|
440
672
|
return false;
|
|
441
673
|
}
|
|
@@ -690,6 +922,7 @@ export class ChannelBase {
|
|
|
690
922
|
await this.loopController?.disable(job.id);
|
|
691
923
|
throw new Error(`Loop ${job.id} target is no longer authorized.`);
|
|
692
924
|
}
|
|
925
|
+
await this.waitForBridgeRecovery();
|
|
693
926
|
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup);
|
|
694
927
|
const label = sanitizeQuotedText(job.label || job.id, 80);
|
|
695
928
|
const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
|
|
@@ -751,6 +984,7 @@ export class ChannelBase {
|
|
|
751
984
|
doneResolve = resolve;
|
|
752
985
|
});
|
|
753
986
|
const promptState = {
|
|
987
|
+
runId: randomUUID(),
|
|
754
988
|
cancelled: false,
|
|
755
989
|
done,
|
|
756
990
|
resolve: doneResolve,
|
|
@@ -809,6 +1043,7 @@ export class ChannelBase {
|
|
|
809
1043
|
heldChunks.length = 0;
|
|
810
1044
|
this.onResponseBoundary(job.target.chatId, sessionId);
|
|
811
1045
|
};
|
|
1046
|
+
await this.waitForBridgeRecovery();
|
|
812
1047
|
const promptBridge = this.bridge;
|
|
813
1048
|
promptBridge.on('textChunk', onChunk);
|
|
814
1049
|
promptBridge.on('responseBoundary', onResponseBoundary);
|
|
@@ -929,6 +1164,7 @@ export class ChannelBase {
|
|
|
929
1164
|
}
|
|
930
1165
|
async runWebhookTask(task, options = {}) {
|
|
931
1166
|
const target = this.resolveWebhookTaskTarget(task);
|
|
1167
|
+
await this.waitForBridgeRecovery();
|
|
932
1168
|
const sessionId = await this.router.resolve(this.name, target.senderId, target.chatId, target.threadId, this.config.cwd, target.isGroup, {
|
|
933
1169
|
routingThreadId: this.webhookRoutingThreadId(task, target),
|
|
934
1170
|
});
|
|
@@ -989,6 +1225,7 @@ export class ChannelBase {
|
|
|
989
1225
|
doneResolve = resolve;
|
|
990
1226
|
});
|
|
991
1227
|
const promptState = {
|
|
1228
|
+
runId: randomUUID(),
|
|
992
1229
|
cancelled: false,
|
|
993
1230
|
done,
|
|
994
1231
|
resolve: doneResolve,
|
|
@@ -1031,6 +1268,7 @@ export class ChannelBase {
|
|
|
1031
1268
|
releaseHeldChunks();
|
|
1032
1269
|
}
|
|
1033
1270
|
};
|
|
1271
|
+
await this.waitForBridgeRecovery();
|
|
1034
1272
|
const promptBridge = this.bridge;
|
|
1035
1273
|
promptBridge.on('textChunk', onChunk);
|
|
1036
1274
|
try {
|
|
@@ -1212,10 +1450,18 @@ export class ChannelBase {
|
|
|
1212
1450
|
active.cancelled = true;
|
|
1213
1451
|
this.stopActiveStreaming(active, sessionId, reason);
|
|
1214
1452
|
this.dropCollectBuffer(sessionId);
|
|
1453
|
+
this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
|
|
1215
1454
|
this.emitTaskCancellation(active, sessionId, reason);
|
|
1216
1455
|
return true;
|
|
1217
1456
|
});
|
|
1218
1457
|
}
|
|
1458
|
+
requestPromptRunCancellation(sessionId, runId, reason = 'cancel_command') {
|
|
1459
|
+
const active = this.activePrompts.get(sessionId);
|
|
1460
|
+
if (!active || active.runId !== runId) {
|
|
1461
|
+
return Promise.resolve(false);
|
|
1462
|
+
}
|
|
1463
|
+
return this.requestActivePromptCancellation(sessionId, reason);
|
|
1464
|
+
}
|
|
1219
1465
|
dropCollectBuffer(sessionId) {
|
|
1220
1466
|
const buffer = this.collectBuffers.get(sessionId);
|
|
1221
1467
|
if (!buffer)
|
|
@@ -1310,7 +1556,12 @@ export class ChannelBase {
|
|
|
1310
1556
|
* Override to implement progressive display (e.g., updating an AI card in-place).
|
|
1311
1557
|
* Default: no-op (chunks are collected internally and delivered via onResponseComplete).
|
|
1312
1558
|
*/
|
|
1313
|
-
onResponseChunk(_chatId, _chunk, _sessionId) { }
|
|
1559
|
+
onResponseChunk(_chatId, _chunk, _sessionId, _segment) { }
|
|
1560
|
+
onOutputSegmentEnd(chatId, sessionId, _segment, reason) {
|
|
1561
|
+
if (reason === 'response_boundary') {
|
|
1562
|
+
return this.onResponseBoundary(chatId, sessionId);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1314
1565
|
/**
|
|
1315
1566
|
* Called when the agent starts a new response segment for the same prompt.
|
|
1316
1567
|
* Override to clear adapter-owned streaming buffers.
|
|
@@ -1322,12 +1573,33 @@ export class ChannelBase {
|
|
|
1322
1573
|
const threadId = active?.threadId ?? target?.threadId;
|
|
1323
1574
|
await this.sendThreadMessage(chatId, threadId, text);
|
|
1324
1575
|
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Adapter hook for delivery-only metadata. The response delivery path can read
|
|
1578
|
+
* the active prompt while it exists; adapters must not retain its raw content.
|
|
1579
|
+
*/
|
|
1580
|
+
getResponseMessageId(sessionId) {
|
|
1581
|
+
return this.activePrompts.get(sessionId)?.messageId;
|
|
1582
|
+
}
|
|
1583
|
+
getResponseSenderId(sessionId) {
|
|
1584
|
+
return this.activePrompts.get(sessionId)?.senderId;
|
|
1585
|
+
}
|
|
1586
|
+
getResponseMetadata(sessionId) {
|
|
1587
|
+
return this.activePrompts.get(sessionId)?.metadata;
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Returns the active prompt's response thread while it remains available for
|
|
1591
|
+
* adapter delivery. Falls back to the session target after prompt cleanup.
|
|
1592
|
+
*/
|
|
1593
|
+
getResponseThreadId(sessionId) {
|
|
1594
|
+
return (this.activePrompts.get(sessionId)?.threadId ??
|
|
1595
|
+
this.router.getTarget(sessionId)?.threadId);
|
|
1596
|
+
}
|
|
1325
1597
|
/**
|
|
1326
1598
|
* Called when the agent's full response is ready.
|
|
1327
1599
|
* Override to customize delivery (e.g., finalize an AI card).
|
|
1328
1600
|
* Default: sends the full response text.
|
|
1329
1601
|
*/
|
|
1330
|
-
async onResponseComplete(chatId, fullText, sessionId) {
|
|
1602
|
+
async onResponseComplete(chatId, fullText, sessionId, _segment) {
|
|
1331
1603
|
await this.sendResponseMessage(chatId, fullText, sessionId);
|
|
1332
1604
|
}
|
|
1333
1605
|
/**
|
|
@@ -1384,12 +1656,13 @@ export class ChannelBase {
|
|
|
1384
1656
|
}
|
|
1385
1657
|
return live;
|
|
1386
1658
|
}
|
|
1387
|
-
removePendingPermission(requestId) {
|
|
1659
|
+
removePendingPermission(requestId, reason = 'resolved_outside_presenter') {
|
|
1388
1660
|
const pending = this.pendingPermissions.get(requestId);
|
|
1389
1661
|
if (!pending) {
|
|
1390
1662
|
return;
|
|
1391
1663
|
}
|
|
1392
1664
|
this.pendingPermissions.delete(requestId);
|
|
1665
|
+
this.settleUserInput(pending, reason);
|
|
1393
1666
|
const chatKey = this.permissionChatKey(pending.target);
|
|
1394
1667
|
const requestIds = this.pendingPermissionsByChat.get(chatKey);
|
|
1395
1668
|
if (!requestIds) {
|
|
@@ -1403,17 +1676,48 @@ export class ChannelBase {
|
|
|
1403
1676
|
this.pendingPermissionsByChat.set(chatKey, remaining);
|
|
1404
1677
|
}
|
|
1405
1678
|
}
|
|
1406
|
-
removePendingPermissionsForSession(sessionId) {
|
|
1679
|
+
removePendingPermissionsForSession(sessionId, reason = 'cancelled') {
|
|
1407
1680
|
const requestIds = Array.from(this.pendingPermissions)
|
|
1408
1681
|
.filter(([, pending]) => pending.sessionId === sessionId)
|
|
1409
1682
|
.map(([requestId]) => requestId);
|
|
1410
1683
|
for (const requestId of requestIds) {
|
|
1411
|
-
this.removePendingPermission(requestId);
|
|
1684
|
+
this.removePendingPermission(requestId, reason);
|
|
1412
1685
|
}
|
|
1413
1686
|
}
|
|
1414
1687
|
clearPendingPermissions() {
|
|
1415
|
-
this.pendingPermissions.
|
|
1416
|
-
|
|
1688
|
+
for (const requestId of Array.from(this.pendingPermissions.keys())) {
|
|
1689
|
+
this.removePendingPermission(requestId, 'cancelled');
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
settleUserInput(pending, reason) {
|
|
1693
|
+
if (pending.settled) {
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
pending.settled = reason;
|
|
1697
|
+
const listeners = Array.from(pending.settlementListeners);
|
|
1698
|
+
pending.settlementListeners.clear();
|
|
1699
|
+
for (const listener of listeners) {
|
|
1700
|
+
try {
|
|
1701
|
+
listener(reason);
|
|
1702
|
+
}
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
process.stderr.write(`[${this.name}] user input settlement listener failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
userInputSettlementReason(pending, outcome) {
|
|
1709
|
+
if (outcome?.outcome === 'cancelled') {
|
|
1710
|
+
return 'cancelled';
|
|
1711
|
+
}
|
|
1712
|
+
if (outcome?.outcome === 'selected') {
|
|
1713
|
+
const selected = pending.request.options.find((option) => option.optionId === outcome.optionId);
|
|
1714
|
+
if (selected?.kind === 'reject_once' ||
|
|
1715
|
+
(selected?.optionId === 'cancel' &&
|
|
1716
|
+
selected.kind === undefined)) {
|
|
1717
|
+
return 'cancelled';
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return 'resolved_outside_presenter';
|
|
1417
1721
|
}
|
|
1418
1722
|
pendingPermissionForEnvelope(envelope, args) {
|
|
1419
1723
|
const trimmed = args.trim();
|
|
@@ -1447,6 +1751,8 @@ export class ChannelBase {
|
|
|
1447
1751
|
canEnvelopeAnswerPendingPermission(envelope, pending) {
|
|
1448
1752
|
return (pending.target.chatId === envelope.chatId &&
|
|
1449
1753
|
pending.target.threadId === envelope.threadId &&
|
|
1754
|
+
(!pending.userInputPresented ||
|
|
1755
|
+
pending.target.senderId === envelope.senderId) &&
|
|
1450
1756
|
(this.isSharedSessionTarget(pending.target) ||
|
|
1451
1757
|
pending.target.senderId === envelope.senderId));
|
|
1452
1758
|
}
|
|
@@ -1541,6 +1847,10 @@ export class ChannelBase {
|
|
|
1541
1847
|
return true;
|
|
1542
1848
|
}
|
|
1543
1849
|
const { pending } = lookup;
|
|
1850
|
+
if (pending.userInputPresented && decision !== 'deny') {
|
|
1851
|
+
await this.sendMessage(envelope.chatId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.');
|
|
1852
|
+
return true;
|
|
1853
|
+
}
|
|
1544
1854
|
const response = (() => {
|
|
1545
1855
|
if (decision === 'deny') {
|
|
1546
1856
|
return this.denialResponse(pending);
|
|
@@ -1560,7 +1870,9 @@ export class ChannelBase {
|
|
|
1560
1870
|
}
|
|
1561
1871
|
let accepted;
|
|
1562
1872
|
try {
|
|
1563
|
-
accepted =
|
|
1873
|
+
accepted = pending.userInputPresented
|
|
1874
|
+
? await this.respondToUserInput(pending, response)
|
|
1875
|
+
: await this.bridge.respondToPermission(pending.requestId, response);
|
|
1564
1876
|
}
|
|
1565
1877
|
catch (err) {
|
|
1566
1878
|
this.removePendingPermission(pending.requestId);
|
|
@@ -1597,7 +1909,7 @@ export class ChannelBase {
|
|
|
1597
1909
|
// already queued onto this session sees a stale generation and bails
|
|
1598
1910
|
// instead of running bridge.prompt() against the cleared session.
|
|
1599
1911
|
this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
|
|
1600
|
-
this.removePendingPermissionsForSession(id);
|
|
1912
|
+
this.removePendingPermissionsForSession(id, 'run_cancelled');
|
|
1601
1913
|
// Cancel an in-flight turn (and drop its buffered follow-ups) before
|
|
1602
1914
|
// purging, so a running prompt can't deliver a stale response into —
|
|
1603
1915
|
// or resurrect via collect-drain — the just-cleared session.
|
|
@@ -3065,6 +3377,17 @@ export class ChannelBase {
|
|
|
3065
3377
|
markPreflighted(envelope) {
|
|
3066
3378
|
this.preflightedEnvelopes.add(envelope);
|
|
3067
3379
|
}
|
|
3380
|
+
/** Wait until the currently active bridge recovery, if any, has completed. */
|
|
3381
|
+
async waitForBridgeRecovery() {
|
|
3382
|
+
let completedRecovery;
|
|
3383
|
+
while (true) {
|
|
3384
|
+
const bridgeRecovery = this.bridgeRecovery?.();
|
|
3385
|
+
if (!bridgeRecovery || bridgeRecovery === completedRecovery)
|
|
3386
|
+
return;
|
|
3387
|
+
await bridgeRecovery;
|
|
3388
|
+
completedRecovery = bridgeRecovery;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3068
3391
|
/**
|
|
3069
3392
|
* Process an inbound message after preflight gates have passed.
|
|
3070
3393
|
*
|
|
@@ -3073,6 +3396,7 @@ export class ChannelBase {
|
|
|
3073
3396
|
* already preflighted, such as during collect-buffer drain.
|
|
3074
3397
|
*/
|
|
3075
3398
|
async processInbound(envelope) {
|
|
3399
|
+
await this.waitForBridgeRecovery();
|
|
3076
3400
|
if (!this.preflightedEnvelopes.delete(envelope)) {
|
|
3077
3401
|
throw new Error('processInbound called without a successful preflightInbound check.');
|
|
3078
3402
|
}
|
|
@@ -3142,6 +3466,9 @@ export class ChannelBase {
|
|
|
3142
3466
|
return;
|
|
3143
3467
|
}
|
|
3144
3468
|
}
|
|
3469
|
+
// Preprocessing above can await memory/command hooks; recovery may have
|
|
3470
|
+
// started since the entry check. Recheck immediately before session routing.
|
|
3471
|
+
await this.waitForBridgeRecovery();
|
|
3145
3472
|
const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
|
|
3146
3473
|
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
3147
3474
|
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
@@ -3328,6 +3655,7 @@ export class ChannelBase {
|
|
|
3328
3655
|
// turn at the channel level (cancelled is already set above), so
|
|
3329
3656
|
// the event reflects that intent, not the bridge RPC outcome.
|
|
3330
3657
|
this.emitTaskCancellation(active, sessionId, 'steer');
|
|
3658
|
+
this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
|
|
3331
3659
|
}
|
|
3332
3660
|
// Diagnostic watchdog: if the predecessor turn is STILL the active prompt
|
|
3333
3661
|
// after the wind-down bound, this steered turn is wedged behind a hung
|
|
@@ -3456,6 +3784,11 @@ export class ChannelBase {
|
|
|
3456
3784
|
doneResolve = r;
|
|
3457
3785
|
});
|
|
3458
3786
|
const promptState = {
|
|
3787
|
+
runId: randomUUID(),
|
|
3788
|
+
owner: {
|
|
3789
|
+
kind: 'channel_user',
|
|
3790
|
+
id: envelope.senderId,
|
|
3791
|
+
},
|
|
3459
3792
|
cancelled: false,
|
|
3460
3793
|
done,
|
|
3461
3794
|
resolve: doneResolve,
|
|
@@ -3465,6 +3798,7 @@ export class ChannelBase {
|
|
|
3465
3798
|
messageId: envelope.messageId,
|
|
3466
3799
|
senderId: envelope.senderId,
|
|
3467
3800
|
senderName: envelope.senderName,
|
|
3801
|
+
metadata: envelope.metadata,
|
|
3468
3802
|
};
|
|
3469
3803
|
// This turn is now the single owner of the session's active-prompt slot.
|
|
3470
3804
|
// (Steer no longer hands a still-active session to a replacement; only
|
|
@@ -3499,12 +3833,13 @@ export class ChannelBase {
|
|
|
3499
3833
|
const releaseHeldChunks = () => {
|
|
3500
3834
|
for (const held of heldChunks.splice(0)) {
|
|
3501
3835
|
hasStreamedText = true;
|
|
3836
|
+
const segment = this.ensureOutputSegment(sessionId, promptState);
|
|
3502
3837
|
this.emitTaskLifecycle({
|
|
3503
3838
|
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
3504
3839
|
type: 'text_chunk',
|
|
3505
3840
|
chunk: held,
|
|
3506
3841
|
});
|
|
3507
|
-
this.onResponseChunk(envelope.chatId, held, sessionId);
|
|
3842
|
+
this.onResponseChunk(envelope.chatId, held, sessionId, segment);
|
|
3508
3843
|
streamer?.push(held);
|
|
3509
3844
|
}
|
|
3510
3845
|
};
|
|
@@ -3525,9 +3860,13 @@ export class ChannelBase {
|
|
|
3525
3860
|
}
|
|
3526
3861
|
heldChunks.length = 0;
|
|
3527
3862
|
hasStreamedText = false;
|
|
3528
|
-
this.
|
|
3863
|
+
const segment = this.closeOutputSegment(sessionId, promptState);
|
|
3864
|
+
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
|
|
3529
3865
|
streamer?.stop();
|
|
3530
3866
|
};
|
|
3867
|
+
// Queue wait and memory recall can outlive a bridge crash. Capture the
|
|
3868
|
+
// bridge only after the latest recovery has restored session routing.
|
|
3869
|
+
await this.waitForBridgeRecovery();
|
|
3531
3870
|
const promptBridge = this.bridge;
|
|
3532
3871
|
promptBridge.on('textChunk', onChunk);
|
|
3533
3872
|
promptBridge.on('responseBoundary', onResponseBoundary);
|
|
@@ -3550,7 +3889,11 @@ export class ChannelBase {
|
|
|
3550
3889
|
await streamer.flush();
|
|
3551
3890
|
}
|
|
3552
3891
|
else {
|
|
3553
|
-
|
|
3892
|
+
const segment = this.ensureOutputSegment(sessionId, promptState);
|
|
3893
|
+
await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
|
|
3894
|
+
if (segment && promptState.activeSegmentId === segment.segmentId) {
|
|
3895
|
+
promptState.activeSegmentId = undefined;
|
|
3896
|
+
}
|
|
3554
3897
|
}
|
|
3555
3898
|
}
|
|
3556
3899
|
// Once delivery started the turn's outcome is fixed — don't let a
|
|
@@ -3559,6 +3902,8 @@ export class ChannelBase {
|
|
|
3559
3902
|
await this.settleCancelRequested(promptState);
|
|
3560
3903
|
}
|
|
3561
3904
|
if (!promptState.cancelled && !promptState.cancellationEmitted) {
|
|
3905
|
+
const segment = this.closeOutputSegment(sessionId, promptState);
|
|
3906
|
+
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
|
|
3562
3907
|
this.emitTaskLifecycle({
|
|
3563
3908
|
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
3564
3909
|
type: 'completed',
|
|
@@ -3574,6 +3919,8 @@ export class ChannelBase {
|
|
|
3574
3919
|
}
|
|
3575
3920
|
if (!promptState.cancelled) {
|
|
3576
3921
|
releaseHeldChunks();
|
|
3922
|
+
const segment = this.closeOutputSegment(sessionId, promptState);
|
|
3923
|
+
void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
|
|
3577
3924
|
this.emitTaskLifecycle({
|
|
3578
3925
|
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
|
|
3579
3926
|
type: 'failed',
|
|
@@ -3587,6 +3934,9 @@ export class ChannelBase {
|
|
|
3587
3934
|
const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
|
|
3588
3935
|
process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
|
|
3589
3936
|
}
|
|
3937
|
+
if (promptState.cancelled) {
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3590
3940
|
throw err;
|
|
3591
3941
|
}
|
|
3592
3942
|
finally {
|