dsh-connect 0.9.0 → 0.9.2
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.i18n.yaml +2 -2
- package/README.md +119 -18
- package/README.zh.md +87 -13
- package/client/client.js +18 -15
- package/client/client.js.map +2 -2
- package/client/settings-client.mjs +22 -13
- package/lib/channels/dingtalk/index.d.ts +49 -49
- package/lib/channels/dingtalk/index.d.ts.map +1 -1
- package/lib/channels/feishu/adapter.d.ts +8 -1
- package/lib/channels/feishu/adapter.d.ts.map +1 -1
- package/lib/channels/feishu/adapter.js +120 -39
- package/lib/channels/feishu/adapter.js.map +1 -1
- package/lib/channels/feishu/index.d.ts +27 -27
- package/lib/channels/feishu/index.d.ts.map +1 -1
- package/lib/channels/telegram/index.d.ts +13 -13
- package/lib/channels/telegram/index.d.ts.map +1 -1
- package/lib/channels/web/adapter.d.ts +3 -1
- package/lib/channels/web/adapter.d.ts.map +1 -1
- package/lib/channels/web/adapter.js +3 -1
- package/lib/channels/web/adapter.js.map +1 -1
- package/lib/channels/web/index.d.ts +5 -5
- package/lib/channels/web/index.d.ts.map +1 -1
- package/lib/index.d.ts +42 -58
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +162 -57
- package/lib/index.js.map +1 -1
- package/lib/interaction.d.ts +90 -50
- package/lib/interaction.d.ts.map +1 -1
- package/lib/interaction.js +233 -272
- package/lib/interaction.js.map +1 -1
- package/lib/retry.d.ts.map +1 -1
- package/lib/retry.js +7 -1
- package/lib/retry.js.map +1 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +0 -2
- package/lib/service.js.map +1 -1
- package/lib/settings/legacy-import.d.ts +190 -0
- package/lib/settings/legacy-import.d.ts.map +1 -0
- package/lib/settings/legacy-import.js +373 -0
- package/lib/settings/legacy-import.js.map +1 -0
- package/lib/settings/namespace.d.ts +204 -116
- package/lib/settings/namespace.d.ts.map +1 -1
- package/lib/settings/namespace.js +235 -148
- package/lib/settings/namespace.js.map +1 -1
- package/lib/settings/settings-model.d.ts.map +1 -1
- package/lib/settings/settings-model.js +10 -1
- package/lib/settings/settings-model.js.map +1 -1
- package/lib/settings/settings-service.d.ts +13 -0
- package/lib/settings/settings-service.d.ts.map +1 -1
- package/lib/settings/settings-service.js +73 -4
- package/lib/settings/settings-service.js.map +1 -1
- package/lib/types.d.ts +14 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +12 -11
package/lib/interaction.js
CHANGED
|
@@ -2,6 +2,15 @@ import { messages } from "./i18n.js";
|
|
|
2
2
|
function delay(ms) {
|
|
3
3
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Whether the host has already given up on a request (its signal fired).
|
|
7
|
+
* A free function on purpose: reading `req.signal?.aborted` directly and
|
|
8
|
+
* re-checking it later would be narrowed away by the first check's early
|
|
9
|
+
* return, even though the signal can fire across an `await`.
|
|
10
|
+
*/
|
|
11
|
+
function hostCancelled(signal) {
|
|
12
|
+
return signal?.aborted === true;
|
|
13
|
+
}
|
|
5
14
|
/**
|
|
6
15
|
* Decode a free-text chat reply into the answer for one question. Exact label
|
|
7
16
|
* match wins; otherwise space/comma separated numbers are mapped onto the
|
|
@@ -40,9 +49,8 @@ function decodeChoice(q, choice) {
|
|
|
40
49
|
return { selected: [opt.label] };
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* rendered as interactive cards and answered via `apiProxy.respond`.
|
|
52
|
+
* Answer the host's user-question and approval waterfalls from the chat
|
|
53
|
+
* channel. Registered once, eagerly, at construction time.
|
|
46
54
|
*/
|
|
47
55
|
export class InteractionBridge {
|
|
48
56
|
ctx;
|
|
@@ -50,27 +58,29 @@ export class InteractionBridge {
|
|
|
50
58
|
bindings;
|
|
51
59
|
config;
|
|
52
60
|
pending = new Map();
|
|
53
|
-
started = false;
|
|
54
|
-
apiProxy;
|
|
55
|
-
controller;
|
|
56
61
|
constructor(ctx, adapters, bindings, config) {
|
|
57
62
|
this.ctx = ctx;
|
|
58
63
|
this.adapters = adapters;
|
|
59
64
|
this.bindings = bindings;
|
|
60
65
|
this.config = config;
|
|
61
|
-
|
|
66
|
+
const seam = ctx;
|
|
67
|
+
ctx.effect(() => this.register(seam, "user-questions/request", this.answerQuestion.bind(this)), "connect: user-questions answerer");
|
|
68
|
+
ctx.effect(() => this.register(seam, "approval/request", this.answerApproval.bind(this)), "connect: approval answerer");
|
|
69
|
+
ctx.effect(() => () => {
|
|
70
|
+
// Teardown: unblock every in-flight card so its loop unwinds.
|
|
71
|
+
for (const p of [...this.pending.values()])
|
|
72
|
+
this.settle(p);
|
|
73
|
+
}, "connect: interaction bridge");
|
|
62
74
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.controller = new AbortController();
|
|
73
|
-
void this.run(this.controller.signal);
|
|
75
|
+
/** Register one waterfall listener, tolerating a host without the event API. */
|
|
76
|
+
register(seam, name, listener) {
|
|
77
|
+
try {
|
|
78
|
+
return seam.on(name, listener, { prepend: true });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
this.log(`connect: could not register the "${name}" answerer: ${String(error)}`);
|
|
82
|
+
return () => undefined;
|
|
83
|
+
}
|
|
74
84
|
}
|
|
75
85
|
/** Whether a question/approval is waiting on an answer in this chat. */
|
|
76
86
|
pendingFor(chatKey) {
|
|
@@ -90,254 +100,153 @@ export class InteractionBridge {
|
|
|
90
100
|
this.recordAnswer(p, q.id, answer);
|
|
91
101
|
return true;
|
|
92
102
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
this.log(`connect: interaction mux stream closed: ${String(error)}`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
onFrame(rpcId, payload) {
|
|
110
|
-
switch (payload.type) {
|
|
111
|
-
case "question/requested": {
|
|
112
|
-
const sessionId = String(payload.sessionId ?? "");
|
|
113
|
-
const questions = Array.isArray(payload.questions) ? payload.questions : [];
|
|
114
|
-
if (sessionId === "" || questions.length === 0)
|
|
115
|
-
return;
|
|
116
|
-
for (const target of this.targetsFor(sessionId)) {
|
|
117
|
-
this.presentQuestion(target, rpcId, sessionId, questions);
|
|
118
|
-
}
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
case "approval/requested": {
|
|
122
|
-
const sessionId = String(payload.sessionId ?? "");
|
|
123
|
-
const approvalId = String(payload.approvalId ?? "");
|
|
124
|
-
if (sessionId === "" || approvalId === "")
|
|
125
|
-
return;
|
|
126
|
-
const toolName = String(payload.toolName ?? "?");
|
|
127
|
-
const reason = typeof payload.reason === "string" ? payload.reason : undefined;
|
|
128
|
-
for (const target of this.targetsFor(sessionId)) {
|
|
129
|
-
this.presentApproval(target, rpcId, sessionId, approvalId, toolName, reason);
|
|
130
|
-
}
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
case "question/resolved": {
|
|
134
|
-
// Someone (Web GUI or this bridge) answered/cancelled the ask.
|
|
135
|
-
const questionRpcId = String(payload.questionRpcId ?? "");
|
|
136
|
-
for (const p of [...this.pending.values()]) {
|
|
137
|
-
if (p.kind === "question" && p.rpcId === questionRpcId)
|
|
138
|
-
this.settle(p);
|
|
139
|
-
}
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
case "session/event": {
|
|
143
|
-
const event = payload.event;
|
|
144
|
-
if (event?.type !== "turn/end")
|
|
145
|
-
return;
|
|
146
|
-
const sessionId = String(payload.sessionId ?? "");
|
|
147
|
-
for (const p of [...this.pending.values()]) {
|
|
148
|
-
if (p.sessionId === sessionId)
|
|
149
|
-
this.settle(p);
|
|
150
|
-
}
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
default:
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
/** Every connect-bound chat that currently uses this session (active or mirrored). */
|
|
158
|
-
targetsFor(sessionId) {
|
|
159
|
-
const out = [];
|
|
160
|
-
for (const binding of this.bindings.list()) {
|
|
161
|
-
if (binding.sessionId !== sessionId && binding.webMirrorSessionId !== sessionId)
|
|
162
|
-
continue;
|
|
163
|
-
const adapter = this.adapters.get(binding.channel);
|
|
164
|
-
if (adapter === undefined)
|
|
165
|
-
continue;
|
|
166
|
-
out.push({ binding, adapter });
|
|
167
|
-
}
|
|
168
|
-
return out;
|
|
169
|
-
}
|
|
170
|
-
presentQuestion(target, rpcId, sessionId, questions) {
|
|
171
|
-
// One pending ask per session — a replayed frame after the mux reopens
|
|
172
|
-
// must not stack duplicate cards.
|
|
173
|
-
for (const p of this.pending.values()) {
|
|
174
|
-
if (p.sessionId === sessionId)
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
103
|
+
// ── user-questions/request ─────────────────────────────────────────────────
|
|
104
|
+
async answerQuestion(req, next) {
|
|
105
|
+
const questions = Array.isArray(req.questions) ? req.questions : [];
|
|
106
|
+
const sessionId = req.agent?.id;
|
|
107
|
+
if (sessionId === undefined || sessionId === "" || questions.length === 0)
|
|
108
|
+
return next();
|
|
109
|
+
if (hostCancelled(req.signal))
|
|
110
|
+
return next();
|
|
111
|
+
const target = this.claimTarget(sessionId);
|
|
112
|
+
if (target === undefined)
|
|
113
|
+
return next();
|
|
177
114
|
const interaction = {
|
|
178
115
|
kind: "question",
|
|
179
116
|
chatKey: target.binding.chatKey,
|
|
180
117
|
chatType: target.binding.chatType,
|
|
181
118
|
adapter: target.adapter,
|
|
182
119
|
sessionId,
|
|
183
|
-
rpcId,
|
|
184
120
|
language: target.binding.language ?? this.config.language,
|
|
185
121
|
questions,
|
|
186
122
|
answers: new Map(),
|
|
187
123
|
current: 0,
|
|
124
|
+
waiters: new Set(),
|
|
188
125
|
controller: new AbortController(),
|
|
189
126
|
settled: false,
|
|
190
127
|
};
|
|
191
128
|
this.pending.set(interaction.chatKey, interaction);
|
|
192
|
-
this.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
return;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
chatType: target.binding.chatType,
|
|
203
|
-
adapter: target.adapter,
|
|
204
|
-
sessionId,
|
|
205
|
-
rpcId,
|
|
206
|
-
approvalId,
|
|
207
|
-
toolName,
|
|
208
|
-
...(reason === undefined ? {} : { reason }),
|
|
209
|
-
language: target.binding.language ?? this.config.language,
|
|
210
|
-
controller: new AbortController(),
|
|
211
|
-
settled: false,
|
|
212
|
-
};
|
|
213
|
-
this.pending.set(interaction.chatKey, interaction);
|
|
214
|
-
void this.presentApprovalLoop(interaction);
|
|
215
|
-
}
|
|
216
|
-
/** Advance to the next unanswered question, or settle once all are answered. */
|
|
217
|
-
stepQuestion(interaction) {
|
|
218
|
-
while (interaction.current < interaction.questions.length &&
|
|
219
|
-
interaction.answers.has(interaction.questions[interaction.current].id)) {
|
|
220
|
-
interaction.current += 1;
|
|
129
|
+
const onAbort = () => this.settle(interaction);
|
|
130
|
+
req.signal?.addEventListener("abort", onAbort, { once: true });
|
|
131
|
+
try {
|
|
132
|
+
const answers = await this.runQuestions(interaction);
|
|
133
|
+
if (answers === undefined)
|
|
134
|
+
return next();
|
|
135
|
+
const t = messages(interaction.language);
|
|
136
|
+
await interaction.adapter.sendText(this.outbound(interaction), t.answerReceived).catch(() => undefined);
|
|
137
|
+
this.log(`connect: answered ${answers.length} question(s) from ${interaction.chatKey}`);
|
|
138
|
+
return { answers };
|
|
221
139
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
140
|
+
catch (error) {
|
|
141
|
+
// The card could not be delivered (or the channel errored): hand the
|
|
142
|
+
// request back so the Web GUI — or the host's fail-closed default — gets it.
|
|
143
|
+
this.log(`connect: question presentation failed (${interaction.chatKey}): ${String(error)}`);
|
|
144
|
+
return next();
|
|
225
145
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
void this.presentQuestionButtons(interaction, q);
|
|
146
|
+
finally {
|
|
147
|
+
req.signal?.removeEventListener("abort", onAbort);
|
|
148
|
+
this.settle(interaction);
|
|
230
149
|
}
|
|
231
|
-
|
|
232
|
-
|
|
150
|
+
}
|
|
151
|
+
/** Ask every question in turn. `undefined` = the flow was interrupted. */
|
|
152
|
+
async runQuestions(interaction) {
|
|
153
|
+
for (let i = 0; i < interaction.questions.length; i += 1) {
|
|
154
|
+
interaction.current = i;
|
|
155
|
+
const q = interaction.questions[i];
|
|
156
|
+
if (interaction.answers.has(q.id))
|
|
157
|
+
continue;
|
|
158
|
+
const answered = await this.askOne(interaction, q);
|
|
159
|
+
if (!answered)
|
|
160
|
+
return undefined;
|
|
233
161
|
}
|
|
162
|
+
return interaction.questions.map((q) => {
|
|
163
|
+
const a = interaction.answers.get(q.id);
|
|
164
|
+
const custom = a?.custom?.trim();
|
|
165
|
+
return {
|
|
166
|
+
id: q.id,
|
|
167
|
+
selected: a?.selected ?? [],
|
|
168
|
+
...(custom === undefined || custom === "" ? {} : { custom }),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
234
171
|
}
|
|
235
|
-
async
|
|
172
|
+
async askOne(interaction, q) {
|
|
173
|
+
const options = Array.isArray(q.options) ? q.options : [];
|
|
174
|
+
if (options.length === 0)
|
|
175
|
+
return this.askByText(interaction, q);
|
|
236
176
|
const t = messages(interaction.language);
|
|
237
|
-
const options = (q.options ?? []).map((o, i) => ({
|
|
238
|
-
id: `q:${q.id}:${i}`,
|
|
239
|
-
label: o.label,
|
|
240
|
-
}));
|
|
241
177
|
const prompt = {
|
|
242
178
|
title: t.questionCardTitle,
|
|
243
179
|
description: this.questionPromptText(interaction, q),
|
|
244
|
-
options,
|
|
180
|
+
options: options.map((o, i) => ({ id: `q:${q.id}:${i}`, label: o.label })),
|
|
245
181
|
footer: q.multiSelect === true ? `${t.questionCardHint}\n${t.questionMultiHint}` : t.questionCardHint,
|
|
246
182
|
};
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
183
|
+
while (!interaction.controller.signal.aborted && !interaction.settled) {
|
|
184
|
+
const { choice, messageId } = await interaction.adapter.promptChoice(this.outbound(interaction), prompt, interaction.messageId, interaction.controller.signal);
|
|
185
|
+
// An adapter aborted before presenting has no card to point at; keep the
|
|
186
|
+
// last real id so the next round still targets the right message.
|
|
187
|
+
if (messageId !== "")
|
|
188
|
+
interaction.messageId = messageId;
|
|
189
|
+
if (choice !== undefined) {
|
|
190
|
+
const answer = decodeChoice(q, choice);
|
|
191
|
+
if (answer !== undefined) {
|
|
192
|
+
this.recordAnswer(interaction, q.id, answer);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// The card expired without a tap. Re-present the same card in place so
|
|
198
|
+
// the choice stays clickable for as long as the agent is waiting.
|
|
199
|
+
if (interaction.controller.signal.aborted || interaction.settled)
|
|
200
|
+
break;
|
|
201
|
+
await delay(1000);
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
258
204
|
}
|
|
259
|
-
|
|
205
|
+
/** Options-free question: the user answers by sending an ordinary message. */
|
|
206
|
+
async askByText(interaction, q) {
|
|
260
207
|
const t = messages(interaction.language);
|
|
261
208
|
const target = this.outbound(interaction);
|
|
262
209
|
await interaction.adapter.sendText(target, t.questionTextHint(q.question)).catch(() => undefined);
|
|
263
210
|
while (!interaction.controller.signal.aborted && !interaction.settled) {
|
|
264
211
|
if (interaction.answers.has(q.id))
|
|
265
|
-
return;
|
|
266
|
-
await
|
|
267
|
-
if (
|
|
268
|
-
return;
|
|
212
|
+
return true;
|
|
213
|
+
const waited = await this.waitForAnswer(interaction, 60_000);
|
|
214
|
+
if (waited === "answered")
|
|
215
|
+
return true;
|
|
216
|
+
if (waited === "stopped")
|
|
217
|
+
return false;
|
|
269
218
|
await interaction.adapter.sendText(target, t.questionWaiting(q.question)).catch(() => undefined);
|
|
270
219
|
}
|
|
271
|
-
|
|
272
|
-
async presentApprovalLoop(interaction) {
|
|
273
|
-
const t = messages(interaction.language);
|
|
274
|
-
const prompt = {
|
|
275
|
-
title: t.approvalCardTitle,
|
|
276
|
-
description: t.approvalAsk(interaction.toolName, interaction.reason),
|
|
277
|
-
options: [
|
|
278
|
-
{ id: "approval:allow", label: t.approveLabel },
|
|
279
|
-
{ id: "approval:reject", label: t.rejectLabel },
|
|
280
|
-
],
|
|
281
|
-
};
|
|
282
|
-
const result = await this.presentLoop(interaction, prompt, async (choice, messageId) => {
|
|
283
|
-
if (choice !== "approval:allow" && choice !== "approval:reject")
|
|
284
|
-
return undefined;
|
|
285
|
-
const outcome = choice === "approval:allow" ? "allowed-once" : "rejected";
|
|
286
|
-
// Submit the decision through the host respond path and wait for the
|
|
287
|
-
// verdict — this is what tells the user whether their tap actually took
|
|
288
|
-
// effect (the Web GUI may have answered first, or the request expired).
|
|
289
|
-
const accepted = await this.respond(interaction.rpcId, {
|
|
290
|
-
sessionId: interaction.sessionId,
|
|
291
|
-
approvalId: interaction.approvalId,
|
|
292
|
-
outcome,
|
|
293
|
-
});
|
|
294
|
-
if (accepted) {
|
|
295
|
-
// Replace the buttons with a clear "done" state so the user sees the
|
|
296
|
-
// decision landed even if the chat keeps streaming afterwards.
|
|
297
|
-
await interaction.adapter
|
|
298
|
-
.closeMenu(messageId, t.approvalDone(outcome, interaction.toolName))
|
|
299
|
-
.catch(() => undefined);
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
// Not accepted: the approval was already handled elsewhere, or is
|
|
303
|
-
// stale. Say so explicitly instead of silently ignoring the tap.
|
|
304
|
-
await interaction.adapter
|
|
305
|
-
.closeMenu(messageId, t.approvalStale)
|
|
306
|
-
.catch(() => undefined);
|
|
307
|
-
await interaction.adapter
|
|
308
|
-
.sendText(this.outbound(interaction), t.approvalStale)
|
|
309
|
-
.catch(() => undefined);
|
|
310
|
-
}
|
|
311
|
-
return "answered";
|
|
312
|
-
});
|
|
313
|
-
if (result === "answered")
|
|
314
|
-
this.settle(interaction);
|
|
220
|
+
return false;
|
|
315
221
|
}
|
|
316
222
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* timeout; on expiry the same card is re-presented in place so the choice
|
|
320
|
-
* stays clickable for as long as the agent waits. Resolves `"answered"`
|
|
321
|
-
* when `onChoice` accepted a choice, `"stopped"` otherwise.
|
|
322
|
-
*
|
|
323
|
-
* `onChoice` may be async and receives the card's message id (for feedback
|
|
324
|
-
* such as updating the card once the decision is delivered). A thrown
|
|
325
|
-
* `onChoice` error resolves the loop as `"stopped"` (best-effort channel).
|
|
223
|
+
* Wait until an answer is recorded, the flow stops, or `timeoutMs` elapses.
|
|
224
|
+
* Resolves (never rejects) and always removes its own listener and timer.
|
|
326
225
|
*/
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (
|
|
333
|
-
return
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
226
|
+
waitForAnswer(interaction, timeoutMs) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
let done = false;
|
|
229
|
+
let timer;
|
|
230
|
+
const finish = (result) => {
|
|
231
|
+
if (done)
|
|
232
|
+
return;
|
|
233
|
+
done = true;
|
|
234
|
+
if (timer !== undefined)
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
interaction.waiters.delete(onWake);
|
|
237
|
+
interaction.controller.signal.removeEventListener("abort", onStop);
|
|
238
|
+
resolve(result);
|
|
239
|
+
};
|
|
240
|
+
const onWake = () => finish("answered");
|
|
241
|
+
const onStop = () => finish("stopped");
|
|
242
|
+
interaction.waiters.add(onWake);
|
|
243
|
+
interaction.controller.signal.addEventListener("abort", onStop, { once: true });
|
|
244
|
+
timer = setTimeout(() => finish("timeout"), timeoutMs);
|
|
245
|
+
timer.unref?.();
|
|
246
|
+
// The signal may have fired between the loop check and this registration.
|
|
247
|
+
if (interaction.controller.signal.aborted)
|
|
248
|
+
finish("stopped");
|
|
249
|
+
});
|
|
341
250
|
}
|
|
342
251
|
questionPromptText(interaction, q) {
|
|
343
252
|
const t = messages(interaction.language);
|
|
@@ -352,57 +261,109 @@ export class InteractionBridge {
|
|
|
352
261
|
if (interaction.answers.has(qid))
|
|
353
262
|
return;
|
|
354
263
|
interaction.answers.set(qid, answer);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
/** All questions answered — deliver the answers through the host respond path. */
|
|
358
|
-
async settleQuestion(interaction) {
|
|
359
|
-
if (interaction.settled)
|
|
360
|
-
return;
|
|
361
|
-
interaction.settled = true;
|
|
362
|
-
const answers = interaction.questions.map((q) => {
|
|
363
|
-
const a = interaction.answers.get(q.id);
|
|
364
|
-
return {
|
|
365
|
-
id: q.id,
|
|
366
|
-
selected: a?.selected ?? [],
|
|
367
|
-
...(a?.custom !== undefined && a.custom.trim() !== "" ? { custom: a.custom.trim() } : {}),
|
|
368
|
-
};
|
|
369
|
-
});
|
|
370
|
-
const accepted = await this.respond(interaction.rpcId, {
|
|
371
|
-
sessionId: interaction.sessionId,
|
|
372
|
-
answer: { answers },
|
|
373
|
-
});
|
|
374
|
-
const t = messages(interaction.language);
|
|
375
|
-
if (accepted) {
|
|
376
|
-
await interaction.adapter.sendText(this.outbound(interaction), t.answerReceived).catch(() => undefined);
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
// The host did not accept the answers (already answered elsewhere, or
|
|
380
|
-
// the question expired) — tell the user their reply had no effect.
|
|
381
|
-
await interaction.adapter.sendText(this.outbound(interaction), t.questionStale).catch(() => undefined);
|
|
382
|
-
}
|
|
383
|
-
this.settle(interaction);
|
|
264
|
+
for (const wake of [...interaction.waiters])
|
|
265
|
+
wake();
|
|
384
266
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
267
|
+
// ── approval/request ───────────────────────────────────────────────────────
|
|
268
|
+
async answerApproval(req, next) {
|
|
269
|
+
const sessionId = req.agent?.id;
|
|
270
|
+
if (sessionId === undefined || sessionId === "" || hostCancelled(req.signal))
|
|
271
|
+
return next();
|
|
272
|
+
const target = this.claimTarget(sessionId);
|
|
273
|
+
if (target === undefined)
|
|
274
|
+
return next();
|
|
275
|
+
const t = messages(target.binding.language ?? this.config.language);
|
|
276
|
+
const interaction = {
|
|
277
|
+
kind: "approval",
|
|
278
|
+
chatKey: target.binding.chatKey,
|
|
279
|
+
chatType: target.binding.chatType,
|
|
280
|
+
adapter: target.adapter,
|
|
281
|
+
sessionId,
|
|
282
|
+
language: target.binding.language ?? this.config.language,
|
|
283
|
+
toolName: req.toolName,
|
|
284
|
+
...(req.reason === undefined ? {} : { reason: req.reason }),
|
|
285
|
+
controller: new AbortController(),
|
|
286
|
+
settled: false,
|
|
287
|
+
};
|
|
288
|
+
this.pending.set(interaction.chatKey, interaction);
|
|
289
|
+
const onAbort = () => this.settle(interaction);
|
|
290
|
+
req.signal?.addEventListener("abort", onAbort, { once: true });
|
|
291
|
+
const prompt = {
|
|
292
|
+
title: t.approvalCardTitle,
|
|
293
|
+
description: t.approvalAsk(req.toolName, req.reason),
|
|
294
|
+
options: [
|
|
295
|
+
{ id: "approval:allow", label: t.approveLabel },
|
|
296
|
+
{ id: "approval:reject", label: t.rejectLabel },
|
|
297
|
+
],
|
|
298
|
+
};
|
|
388
299
|
try {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
300
|
+
while (!interaction.controller.signal.aborted && !interaction.settled) {
|
|
301
|
+
const { choice, messageId } = await interaction.adapter.promptChoice(this.outbound(interaction), prompt, interaction.messageId, interaction.controller.signal);
|
|
302
|
+
if (messageId !== "")
|
|
303
|
+
interaction.messageId = messageId;
|
|
304
|
+
if (choice !== "approval:allow" && choice !== "approval:reject") {
|
|
305
|
+
if (interaction.controller.signal.aborted || interaction.settled)
|
|
306
|
+
break;
|
|
307
|
+
// Card expired without a tap — re-present it and keep waiting. The
|
|
308
|
+
// host cancels the request itself if it stops caring (its signal
|
|
309
|
+
// fires, which aborts us and lands on `next()` below).
|
|
310
|
+
await delay(1000);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (hostCancelled(req.signal)) {
|
|
314
|
+
// Decided, but too late: the host already gave up on this request.
|
|
315
|
+
await interaction.adapter.closeMenu(messageId, t.approvalStale).catch(() => undefined);
|
|
316
|
+
return next();
|
|
317
|
+
}
|
|
318
|
+
const outcome = choice === "approval:allow" ? "allowed-once" : "rejected";
|
|
319
|
+
// Replace the buttons with a clear "done" state so the user sees the
|
|
320
|
+
// decision landed even if the chat keeps streaming afterwards.
|
|
321
|
+
await interaction.adapter.closeMenu(messageId, t.approvalDone(outcome, req.toolName)).catch(() => undefined);
|
|
322
|
+
this.log(`connect: approval ${outcome} for ${req.toolName} from ${interaction.chatKey}`);
|
|
323
|
+
return outcome;
|
|
324
|
+
}
|
|
325
|
+
return next();
|
|
395
326
|
}
|
|
396
327
|
catch (error) {
|
|
397
|
-
this.log(`connect:
|
|
398
|
-
return
|
|
328
|
+
this.log(`connect: approval presentation failed (${interaction.chatKey}): ${String(error)}`);
|
|
329
|
+
return next();
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
req.signal?.removeEventListener("abort", onAbort);
|
|
333
|
+
this.settle(interaction);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ── shared ─────────────────────────────────────────────────────────────────
|
|
337
|
+
/**
|
|
338
|
+
* Pick the one chat that owns `sessionId` and can actually present a prompt.
|
|
339
|
+
*
|
|
340
|
+
* Only the first match is claimed: two cards for one question would leave the
|
|
341
|
+
* second one unanswerable (the answer is delivered to the agent, not to a
|
|
342
|
+
* specific card), and the Web GUI would render a duplicate.
|
|
343
|
+
*
|
|
344
|
+
* The `web` channel is a GUI mirror whose outbound face is a no-op by design
|
|
345
|
+
* — it can neither show a card nor report a tap, so claiming it would spin
|
|
346
|
+
* the re-present loop forever. Declining it leaves the request to the host's
|
|
347
|
+
* own forwarder, which is precisely the Web GUI.
|
|
348
|
+
*/
|
|
349
|
+
claimTarget(sessionId) {
|
|
350
|
+
for (const binding of this.bindings.list()) {
|
|
351
|
+
if (binding.sessionId !== sessionId && binding.webMirrorSessionId !== sessionId)
|
|
352
|
+
continue;
|
|
353
|
+
const adapter = this.adapters.get(binding.channel);
|
|
354
|
+
if (adapter === undefined || adapter.supportsChoices === false)
|
|
355
|
+
continue;
|
|
356
|
+
if (this.pending.has(binding.chatKey))
|
|
357
|
+
continue;
|
|
358
|
+
return { binding, adapter };
|
|
399
359
|
}
|
|
360
|
+
return undefined;
|
|
400
361
|
}
|
|
401
|
-
/** Stop presenting and drop the interaction (answered, cancelled, or
|
|
362
|
+
/** Stop presenting and drop the interaction (answered, cancelled, or torn down). */
|
|
402
363
|
settle(interaction) {
|
|
364
|
+
interaction.settled = true;
|
|
403
365
|
if (this.pending.get(interaction.chatKey) === interaction)
|
|
404
366
|
this.pending.delete(interaction.chatKey);
|
|
405
|
-
interaction.settled = true;
|
|
406
367
|
interaction.controller.abort();
|
|
407
368
|
}
|
|
408
369
|
outbound(p) {
|