@xmanrui/dsh-im 3.2.0 → 4.0.1
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.en.md +1 -1
- package/README.md +1 -1
- package/lib/client.js +11 -3
- package/lib/index.js +212 -212
- package/package.json +1 -1
- package/plugin-src/client/index.js +13 -2
- package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
- package/plugin-src/host/channels/discord/index.mjs +1 -1
- package/plugin-src/host/channels/feishu/index.mjs +1 -1
- package/plugin-src/host/channels/qq/index.mjs +1 -1
- package/plugin-src/host/channels/slack/index.mjs +1 -1
- package/plugin-src/host/channels/telegram/index.mjs +1 -1
- package/plugin-src/host/channels/wecom/index.mjs +1 -1
- package/plugin-src/host/channels/weixin/index.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
- package/plugin-src/host/harness-connection.mjs +9 -4
- package/plugin-src/host/index.mjs +43 -29
- package/plugin-src/host/modern-harness-api.mjs +577 -0
- package/src/channels/shared/harness-client.mjs +18 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
|
|
4
|
+
|
|
5
|
+
const modernApis = new WeakMap();
|
|
6
|
+
|
|
7
|
+
function failureOf(error) {
|
|
8
|
+
const failure = error?.failure;
|
|
9
|
+
if (failure && typeof failure === 'object'
|
|
10
|
+
&& typeof failure.code === 'string'
|
|
11
|
+
&& typeof failure.message === 'string') {
|
|
12
|
+
return {
|
|
13
|
+
code: failure.code,
|
|
14
|
+
message: failure.message,
|
|
15
|
+
details: failure.details && typeof failure.details === 'object' ? failure.details : {},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (error?.name === 'AbortError' || error?.name === 'RemoteInvocationCancelled') {
|
|
19
|
+
return {
|
|
20
|
+
code: 'cancelled',
|
|
21
|
+
message: error instanceof Error ? error.message : 'Harness request was cancelled',
|
|
22
|
+
details: {},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
code: 'internal',
|
|
27
|
+
message: error instanceof Error ? error.message : String(error),
|
|
28
|
+
details: {},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rpcResult(request, operation) {
|
|
33
|
+
return Promise.resolve().then(operation).then(
|
|
34
|
+
(value) => ({ rpcId: request.rpcId, result: { ok: true, value } }),
|
|
35
|
+
(error) => ({ rpcId: request.rpcId, result: { ok: false, error: failureOf(error) } }),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function remoteRequest(namespace, method, args, signal) {
|
|
40
|
+
return {
|
|
41
|
+
namespace,
|
|
42
|
+
method,
|
|
43
|
+
args,
|
|
44
|
+
...(signal === undefined ? {} : { signal }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function questionError(message, code) {
|
|
49
|
+
const error = new Error(message);
|
|
50
|
+
error.name = 'UserQuestionError';
|
|
51
|
+
error.code = code;
|
|
52
|
+
return error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchesQuestions(value, pending) {
|
|
56
|
+
if (!value || typeof value !== 'object'
|
|
57
|
+
|| value.sessionId !== pending.sessionId
|
|
58
|
+
|| !value.answer || typeof value.answer !== 'object'
|
|
59
|
+
|| !Array.isArray(value.answer.answers)
|
|
60
|
+
|| value.answer.answers.length !== pending.questions.length) return false;
|
|
61
|
+
return value.answer.answers.every((answer, index) => {
|
|
62
|
+
const question = pending.questions[index];
|
|
63
|
+
if (!answer || typeof answer !== 'object'
|
|
64
|
+
|| answer.id !== question.id
|
|
65
|
+
|| !Array.isArray(answer.selected)
|
|
66
|
+
|| answer.selected.some((label) => typeof label !== 'string')
|
|
67
|
+
|| new Set(answer.selected).size !== answer.selected.length
|
|
68
|
+
|| (answer.custom !== undefined && typeof answer.custom !== 'string')) return false;
|
|
69
|
+
const custom = answer.custom?.trim();
|
|
70
|
+
if (custom !== undefined && !custom) return false;
|
|
71
|
+
if (question.multiSelect !== true
|
|
72
|
+
&& ((custom !== undefined && answer.selected.length > 0) || answer.selected.length > 1)) return false;
|
|
73
|
+
const labels = new Set(question.options?.map((option) => option.label) ?? []);
|
|
74
|
+
return answer.selected.every((label) => labels.has(label));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function expandChunkRecord(record) {
|
|
79
|
+
if (record?.type === 'event' && record.event && typeof record.event === 'object') {
|
|
80
|
+
return [{ event: record.event }];
|
|
81
|
+
}
|
|
82
|
+
const packed = record?.type === 'chunks' ? record.event : null;
|
|
83
|
+
if (!packed || typeof packed !== 'object') {
|
|
84
|
+
throw new Error('Harness returned an invalid session history record');
|
|
85
|
+
}
|
|
86
|
+
const { data } = packed;
|
|
87
|
+
const kind = packed.type;
|
|
88
|
+
const members = kind === 'chunkrow/tool-call-chunks' ? data?.args : data?.texts;
|
|
89
|
+
if (!data || !Array.isArray(members) || members.length === 0
|
|
90
|
+
|| members.some((member) => typeof member !== 'string')
|
|
91
|
+
|| !Array.isArray(data.dt) || data.dt.length !== members.length - 1) {
|
|
92
|
+
throw new Error('Harness returned an invalid packed session chunk');
|
|
93
|
+
}
|
|
94
|
+
let time = packed.time;
|
|
95
|
+
return members.map((member, index) => {
|
|
96
|
+
if (index > 0) time += data.dt[index - 1];
|
|
97
|
+
let chunk;
|
|
98
|
+
if (kind === 'chunkrow/text-chunks') {
|
|
99
|
+
chunk = { type: 'text-delta', index: data.index, text: member };
|
|
100
|
+
} else if (kind === 'chunkrow/reasoning-chunks') {
|
|
101
|
+
chunk = { type: 'reasoning-delta', index: data.index, text: member };
|
|
102
|
+
} else if (kind === 'chunkrow/tool-call-chunks') {
|
|
103
|
+
chunk = {
|
|
104
|
+
type: 'tool-call-delta',
|
|
105
|
+
index: data.index,
|
|
106
|
+
id: data.id,
|
|
107
|
+
...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
|
|
108
|
+
argumentsDelta: member,
|
|
109
|
+
};
|
|
110
|
+
} else {
|
|
111
|
+
throw new Error(`Harness returned an unsupported packed session chunk: ${String(kind)}`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
event: {
|
|
115
|
+
type: 'assistant/chunk',
|
|
116
|
+
seq: packed.seq + index,
|
|
117
|
+
time,
|
|
118
|
+
data: { turn: data.turn, step: data.step, chunk },
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function historyEntries(records) {
|
|
125
|
+
if (!Array.isArray(records)) throw new Error('Harness returned invalid session history records');
|
|
126
|
+
return records.flatMap(expandChunkRecord);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class MuxSubscription {
|
|
130
|
+
#frames = [];
|
|
131
|
+
#waiting = null;
|
|
132
|
+
#closed = false;
|
|
133
|
+
#signal;
|
|
134
|
+
#onAbort;
|
|
135
|
+
#dispose;
|
|
136
|
+
|
|
137
|
+
constructor(signal, dispose) {
|
|
138
|
+
this.#signal = signal;
|
|
139
|
+
this.#dispose = dispose;
|
|
140
|
+
this.#onAbort = () => this.close();
|
|
141
|
+
signal?.addEventListener('abort', this.#onAbort, { once: true });
|
|
142
|
+
if (signal?.aborted) this.close();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
push(frame) {
|
|
146
|
+
if (this.#closed) return;
|
|
147
|
+
if (this.#waiting) {
|
|
148
|
+
const resolve = this.#waiting;
|
|
149
|
+
this.#waiting = null;
|
|
150
|
+
resolve({ value: frame, done: false });
|
|
151
|
+
} else {
|
|
152
|
+
this.#frames.push(frame);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
next() {
|
|
157
|
+
if (this.#frames.length > 0) {
|
|
158
|
+
return Promise.resolve({ value: this.#frames.shift(), done: false });
|
|
159
|
+
}
|
|
160
|
+
if (this.#closed) return Promise.resolve({ value: undefined, done: true });
|
|
161
|
+
return new Promise((resolve) => { this.#waiting = resolve; });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return() {
|
|
165
|
+
this.close();
|
|
166
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
close() {
|
|
170
|
+
if (this.#closed) return;
|
|
171
|
+
this.#closed = true;
|
|
172
|
+
this.#signal?.removeEventListener('abort', this.#onAbort);
|
|
173
|
+
this.#dispose?.();
|
|
174
|
+
this.#dispose = null;
|
|
175
|
+
if (this.#waiting) {
|
|
176
|
+
const resolve = this.#waiting;
|
|
177
|
+
this.#waiting = null;
|
|
178
|
+
resolve({ value: undefined, done: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
[Symbol.asyncIterator]() {
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
class ModernHarnessApi {
|
|
188
|
+
#gateway;
|
|
189
|
+
#scope;
|
|
190
|
+
#mux = new Set();
|
|
191
|
+
#pendingQuestions = new Map();
|
|
192
|
+
#pendingApprovals = new Map();
|
|
193
|
+
#sessionCursors = new Map();
|
|
194
|
+
#disposers = [];
|
|
195
|
+
#disposed = false;
|
|
196
|
+
|
|
197
|
+
constructor(ctx, gateway, scope) {
|
|
198
|
+
this.#gateway = gateway;
|
|
199
|
+
this.#scope = scope;
|
|
200
|
+
|
|
201
|
+
this.host = Object.freeze({
|
|
202
|
+
describe: (request) => rpcResult(request, () => ({ ready: true, transport: 'typert' })),
|
|
203
|
+
});
|
|
204
|
+
this.workspace = Object.freeze({
|
|
205
|
+
list: (request, signal) => rpcResult(request, () => this.#workspaceList(signal)),
|
|
206
|
+
create: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
207
|
+
'workspace', 'create', { request: request.payload }, signal,
|
|
208
|
+
)),
|
|
209
|
+
});
|
|
210
|
+
this.sessions = Object.freeze({
|
|
211
|
+
list: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
212
|
+
'session', 'list', { _request: request.payload }, signal,
|
|
213
|
+
)),
|
|
214
|
+
create: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
215
|
+
'session', 'create', { request: request.payload }, signal,
|
|
216
|
+
)),
|
|
217
|
+
history: (request, signal) => rpcResult(request, () => this.#history(request.payload, signal)),
|
|
218
|
+
prompt: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
219
|
+
'session',
|
|
220
|
+
'prompt',
|
|
221
|
+
{ request: { requestId: request.rpcId, ...request.payload } },
|
|
222
|
+
signal,
|
|
223
|
+
)),
|
|
224
|
+
cancel: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
225
|
+
'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
|
|
226
|
+
)),
|
|
227
|
+
models: (request, signal) => rpcResult(request, () => this.#sessionModels(
|
|
228
|
+
request.payload.sessionId, signal,
|
|
229
|
+
)),
|
|
230
|
+
selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
231
|
+
'session', 'selectModel', { request: request.payload }, signal,
|
|
232
|
+
)),
|
|
233
|
+
});
|
|
234
|
+
this.llm = Object.freeze({
|
|
235
|
+
models: (request, signal) => rpcResult(request, async () => {
|
|
236
|
+
const catalog = await this.#modelCatalog(signal);
|
|
237
|
+
return { groups: catalog.groups, failures: catalog.failures };
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
this.events = Object.freeze({
|
|
241
|
+
mux: (_request, signal) => this.#openMux(signal),
|
|
242
|
+
});
|
|
243
|
+
this.respond = (message) => Promise.resolve(this.#respond(message));
|
|
244
|
+
|
|
245
|
+
if (typeof ctx?.on === 'function') {
|
|
246
|
+
this.#disposers.push(ctx.on('session/event', (session, event) => {
|
|
247
|
+
const sessionId = session?.id;
|
|
248
|
+
if (typeof sessionId !== 'string' || !event || typeof event !== 'object') return;
|
|
249
|
+
if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
|
|
250
|
+
this.#broadcast({ type: 'session/event', sessionId, event });
|
|
251
|
+
}, { global: true }));
|
|
252
|
+
this.#disposers.push(ctx.on(
|
|
253
|
+
'approval/request',
|
|
254
|
+
(request, next) => this.#requestApproval(request, next),
|
|
255
|
+
{ global: true, prepend: true },
|
|
256
|
+
));
|
|
257
|
+
this.#disposers.push(ctx.on(
|
|
258
|
+
'user-questions/request',
|
|
259
|
+
(request, next) => this.#requestQuestion(request, next),
|
|
260
|
+
{ global: true, prepend: true },
|
|
261
|
+
));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #invoke(namespace, method, args, signal) {
|
|
266
|
+
return this.#gateway.invoke(remoteRequest(namespace, method, args, signal));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async #streamFirst(namespace, method, args, signal) {
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const streamSignal = signal
|
|
272
|
+
? AbortSignal.any([signal, controller.signal])
|
|
273
|
+
: controller.signal;
|
|
274
|
+
let iterator;
|
|
275
|
+
try {
|
|
276
|
+
const source = await this.#gateway.stream(remoteRequest(
|
|
277
|
+
namespace, method, args, streamSignal,
|
|
278
|
+
));
|
|
279
|
+
iterator = source[Symbol.asyncIterator]();
|
|
280
|
+
const first = await iterator.next();
|
|
281
|
+
if (first.done) throw new Error(`Harness ${namespace}.${method} stream ended before its baseline`);
|
|
282
|
+
return first.value;
|
|
283
|
+
} finally {
|
|
284
|
+
controller.abort(new DOMException('Baseline received', 'AbortError'));
|
|
285
|
+
await Promise.resolve(iterator?.return?.()).catch(() => undefined);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async #workspaceList(signal) {
|
|
290
|
+
const frame = await this.#streamFirst('workspace', 'follow', {}, signal);
|
|
291
|
+
if (frame?.type !== 'baseline' || !frame.value || typeof frame.value !== 'object') {
|
|
292
|
+
throw new Error('Harness workspace.follow returned no baseline');
|
|
293
|
+
}
|
|
294
|
+
return frame.value;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async #sessionSnapshot(sessionId, maxMessages, signal) {
|
|
298
|
+
const frame = await this.#streamFirst('session', 'follow', {
|
|
299
|
+
request: {
|
|
300
|
+
address: { kind: 'session', sessionId },
|
|
301
|
+
maxMessages,
|
|
302
|
+
},
|
|
303
|
+
}, signal);
|
|
304
|
+
if (frame?.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
|
|
305
|
+
throw new Error('Harness session.follow returned no snapshot');
|
|
306
|
+
}
|
|
307
|
+
this.#rememberCursor(sessionId, frame.cursor);
|
|
308
|
+
return frame;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async #history(payload, signal) {
|
|
312
|
+
const { sessionId, maxMessages = 50, beforeSeq } = payload;
|
|
313
|
+
let cursor = this.#sessionCursors.get(sessionId);
|
|
314
|
+
if (cursor === undefined) {
|
|
315
|
+
const snapshot = await this.#sessionSnapshot(sessionId, maxMessages, signal);
|
|
316
|
+
cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
|
|
317
|
+
if (beforeSeq === undefined && cursor === snapshot.cursor) {
|
|
318
|
+
return {
|
|
319
|
+
events: historyEntries(snapshot.records),
|
|
320
|
+
hasMore: snapshot.hasMore === true,
|
|
321
|
+
...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const page = await this.#invoke('session', 'page', {
|
|
326
|
+
request: {
|
|
327
|
+
address: { kind: 'session', sessionId },
|
|
328
|
+
throughSeq: cursor,
|
|
329
|
+
maxMessages,
|
|
330
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
331
|
+
},
|
|
332
|
+
}, signal);
|
|
333
|
+
return { events: historyEntries(page.records), hasMore: page.hasMore === true };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#rememberCursor(sessionId, cursor) {
|
|
337
|
+
const previous = this.#sessionCursors.get(sessionId);
|
|
338
|
+
if (previous === undefined || cursor > previous) this.#sessionCursors.set(sessionId, cursor);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#modelCatalog(signal) {
|
|
342
|
+
return this.#invoke('session', 'modelCatalog', {}, signal);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async #sessionModels(sessionId, signal) {
|
|
346
|
+
const [catalog, list] = await Promise.all([
|
|
347
|
+
this.#modelCatalog(signal),
|
|
348
|
+
this.#invoke('session', 'list', { _request: {} }, signal),
|
|
349
|
+
]);
|
|
350
|
+
const summary = list?.items?.find((item) => item?.sessionId === sessionId);
|
|
351
|
+
if (!summary) {
|
|
352
|
+
const error = new Error(`session "${sessionId}" not found`);
|
|
353
|
+
error.failure = {
|
|
354
|
+
code: 'session-not-found',
|
|
355
|
+
message: error.message,
|
|
356
|
+
details: { sessionId },
|
|
357
|
+
};
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const selection = summary.projections?.values?.modelSelection;
|
|
361
|
+
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
|
|
362
|
+
return {
|
|
363
|
+
current,
|
|
364
|
+
routable: catalog.routableProviders.includes(current.provider),
|
|
365
|
+
groups: catalog.groups,
|
|
366
|
+
failures: catalog.failures,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#openMux(signal) {
|
|
371
|
+
let subscription;
|
|
372
|
+
subscription = new MuxSubscription(signal, () => this.#mux.delete(subscription));
|
|
373
|
+
this.#mux.add(subscription);
|
|
374
|
+
for (const pending of this.#pendingQuestions.values()) subscription.push(this.#questionFrame(pending));
|
|
375
|
+
for (const pending of this.#pendingApprovals.values()) subscription.push(this.#approvalFrame(pending));
|
|
376
|
+
return subscription;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#broadcast(payload, rpcId = randomUUID()) {
|
|
380
|
+
const frame = { rpcId, payload };
|
|
381
|
+
for (const subscription of this.#mux) subscription.push(frame);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#claimableAgent(agent) {
|
|
385
|
+
const sessionId = agent?.session?.id ?? agent?.id;
|
|
386
|
+
if (typeof sessionId !== 'string' || !agent?.session || !Array.isArray(agent.session.events)) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return hasActiveHarnessInteractionOwner(
|
|
390
|
+
this.#scope,
|
|
391
|
+
sessionId,
|
|
392
|
+
agent.session.events,
|
|
393
|
+
) ? { sessionId, session: agent.session } : null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#questionFrame(pending) {
|
|
397
|
+
return {
|
|
398
|
+
rpcId: pending.rpcId,
|
|
399
|
+
payload: {
|
|
400
|
+
type: 'question/requested',
|
|
401
|
+
sessionId: pending.sessionId,
|
|
402
|
+
questions: pending.questions,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#requestQuestion(request, next) {
|
|
408
|
+
const owner = this.#claimableAgent(request?.agent);
|
|
409
|
+
if (!owner) return next();
|
|
410
|
+
if (request.signal?.aborted) {
|
|
411
|
+
return Promise.reject(questionError(
|
|
412
|
+
'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
|
|
413
|
+
));
|
|
414
|
+
}
|
|
415
|
+
return new Promise((resolve, reject) => {
|
|
416
|
+
const pending = {
|
|
417
|
+
rpcId: randomUUID(),
|
|
418
|
+
sessionId: owner.sessionId,
|
|
419
|
+
questions: request.questions,
|
|
420
|
+
signal: request.signal,
|
|
421
|
+
settle: (outcome, value) => {
|
|
422
|
+
if (!this.#pendingQuestions.delete(pending.rpcId)) return;
|
|
423
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
424
|
+
this.#broadcast({
|
|
425
|
+
type: 'question/resolved',
|
|
426
|
+
sessionId: pending.sessionId,
|
|
427
|
+
questionRpcId: pending.rpcId,
|
|
428
|
+
outcome,
|
|
429
|
+
});
|
|
430
|
+
if (outcome === 'answered') resolve(value);
|
|
431
|
+
else reject(value);
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
const onAbort = () => pending.settle('cancelled', questionError(
|
|
435
|
+
'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
|
|
436
|
+
));
|
|
437
|
+
this.#pendingQuestions.set(pending.rpcId, pending);
|
|
438
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
439
|
+
this.#broadcast(this.#questionFrame(pending).payload, pending.rpcId);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#approvalFrame(pending) {
|
|
444
|
+
return {
|
|
445
|
+
rpcId: pending.rpcId,
|
|
446
|
+
payload: {
|
|
447
|
+
type: 'approval/requested',
|
|
448
|
+
sessionId: pending.sessionId,
|
|
449
|
+
approvalId: pending.approvalId,
|
|
450
|
+
toolName: pending.toolName,
|
|
451
|
+
...(pending.callId === undefined ? {} : { callId: pending.callId }),
|
|
452
|
+
...(pending.reason === undefined ? {} : { reason: pending.reason }),
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#requestApproval(request, next) {
|
|
458
|
+
const owner = this.#claimableAgent(request?.agent);
|
|
459
|
+
if (!owner) return next();
|
|
460
|
+
if (request.signal?.aborted) return Promise.resolve('cancelled');
|
|
461
|
+
const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
|
|
462
|
+
const decided = new Set();
|
|
463
|
+
let approvalId;
|
|
464
|
+
for (let index = owner.session.events.length - 1; index >= 0; index -= 1) {
|
|
465
|
+
const event = owner.session.events[index];
|
|
466
|
+
if (event.type === 'approval/decided') {
|
|
467
|
+
decided.add(event.data?.id);
|
|
468
|
+
} else if (event.type === 'approval/asked') {
|
|
469
|
+
const id = event.data?.id;
|
|
470
|
+
if (!id || decided.has(id) || claimed.has(id)) continue;
|
|
471
|
+
if ((request.callId ?? null) !== (event.data?.callId ?? null)) continue;
|
|
472
|
+
approvalId = id;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (approvalId === undefined) return next();
|
|
477
|
+
return new Promise((resolve) => {
|
|
478
|
+
const pending = {
|
|
479
|
+
rpcId: randomUUID(),
|
|
480
|
+
sessionId: owner.sessionId,
|
|
481
|
+
approvalId,
|
|
482
|
+
toolName: request.toolName,
|
|
483
|
+
callId: request.callId,
|
|
484
|
+
reason: request.reason,
|
|
485
|
+
settle: (outcome) => {
|
|
486
|
+
if (!this.#pendingApprovals.delete(pending.rpcId)) return;
|
|
487
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
488
|
+
this.#broadcast({
|
|
489
|
+
type: 'approval/resolved',
|
|
490
|
+
sessionId: pending.sessionId,
|
|
491
|
+
approvalId: pending.approvalId,
|
|
492
|
+
outcome,
|
|
493
|
+
});
|
|
494
|
+
resolve(outcome);
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
const onAbort = () => pending.settle('cancelled');
|
|
498
|
+
this.#pendingApprovals.set(pending.rpcId, pending);
|
|
499
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
500
|
+
this.#broadcast(this.#approvalFrame(pending).payload, pending.rpcId);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#respond(message) {
|
|
505
|
+
const approval = this.#pendingApprovals.get(message?.rpcId);
|
|
506
|
+
if (approval) {
|
|
507
|
+
const value = message?.result?.value;
|
|
508
|
+
if (message?.result?.ok !== true
|
|
509
|
+
|| !value || typeof value !== 'object'
|
|
510
|
+
|| value.sessionId !== approval.sessionId
|
|
511
|
+
|| value.approvalId !== approval.approvalId
|
|
512
|
+
|| (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
|
|
513
|
+
return { accepted: false, reason: 'bad-response' };
|
|
514
|
+
}
|
|
515
|
+
approval.settle(value.outcome);
|
|
516
|
+
return { accepted: true };
|
|
517
|
+
}
|
|
518
|
+
const question = this.#pendingQuestions.get(message?.rpcId);
|
|
519
|
+
if (!question) return { accepted: false, reason: 'not-pending' };
|
|
520
|
+
if (message?.result?.ok !== true) {
|
|
521
|
+
if (message?.result?.error?.code !== 'cancelled') {
|
|
522
|
+
return { accepted: false, reason: 'bad-response' };
|
|
523
|
+
}
|
|
524
|
+
question.settle('cancelled', questionError(
|
|
525
|
+
'the user cancelled ask_user_question', 'ASK_CANCELLED',
|
|
526
|
+
));
|
|
527
|
+
return { accepted: true };
|
|
528
|
+
}
|
|
529
|
+
if (!matchesQuestions(message.result.value, question)) {
|
|
530
|
+
return { accepted: false, reason: 'bad-response' };
|
|
531
|
+
}
|
|
532
|
+
const answer = {
|
|
533
|
+
answers: message.result.value.answer.answers.map((item) => ({
|
|
534
|
+
id: item.id,
|
|
535
|
+
selected: [...item.selected],
|
|
536
|
+
...(item.custom === undefined ? {} : { custom: item.custom }),
|
|
537
|
+
})),
|
|
538
|
+
};
|
|
539
|
+
question.settle('answered', answer);
|
|
540
|
+
return { accepted: true };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
dispose() {
|
|
544
|
+
if (this.#disposed) return;
|
|
545
|
+
this.#disposed = true;
|
|
546
|
+
for (const subscription of [...this.#mux]) subscription.close();
|
|
547
|
+
for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
|
|
548
|
+
for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
|
|
549
|
+
'cancelled',
|
|
550
|
+
questionError('dsh-im interaction adapter was disposed', 'ASK_ABORTED'),
|
|
551
|
+
);
|
|
552
|
+
for (const dispose of this.#disposers.splice(0).reverse()) dispose?.();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Create one modern compatibility API per Cordis Host root. */
|
|
557
|
+
export function modernHarnessApi(ctx) {
|
|
558
|
+
const scope = ctx?.root ?? ctx;
|
|
559
|
+
if (!scope || !['object', 'function'].includes(typeof scope)) {
|
|
560
|
+
throw new TypeError('dsh-im requires a Cordis Host context');
|
|
561
|
+
}
|
|
562
|
+
const cached = modernApis.get(scope);
|
|
563
|
+
if (cached) return cached;
|
|
564
|
+
const gateway = ctx?.typertGateway;
|
|
565
|
+
if (!gateway || typeof gateway.invoke !== 'function' || typeof gateway.stream !== 'function') {
|
|
566
|
+
throw new TypeError('dsh-im requires the modern Host Typert gateway');
|
|
567
|
+
}
|
|
568
|
+
const api = new ModernHarnessApi(ctx, gateway, scope);
|
|
569
|
+
modernApis.set(scope, api);
|
|
570
|
+
if (typeof ctx.effect === 'function') {
|
|
571
|
+
ctx.effect(() => () => {
|
|
572
|
+
if (modernApis.get(scope) === api) modernApis.delete(scope);
|
|
573
|
+
api.dispose();
|
|
574
|
+
}, 'dsh-im: modern Harness compatibility API');
|
|
575
|
+
}
|
|
576
|
+
return api;
|
|
577
|
+
}
|
|
@@ -389,6 +389,24 @@ function consumeInteractionOwnership(ownership, entries) {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Decide whether the current Host has a live dsh-im interaction watcher for
|
|
394
|
+
* this Session. The modern Harness adapter uses the same ownership registry
|
|
395
|
+
* as HarnessClient so it never steals a browser-owned question or approval.
|
|
396
|
+
*/
|
|
397
|
+
export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) {
|
|
398
|
+
if (!scope || !['object', 'function', 'string'].includes(typeof scope)
|
|
399
|
+
|| typeof sessionId !== 'string' || !sessionId) return false;
|
|
400
|
+
const owners = interactionRegistry(scope).ownerships.get(sessionId);
|
|
401
|
+
if (!owners || owners.size === 0) return false;
|
|
402
|
+
for (const ownership of owners) consumeInteractionOwnership(ownership, entries);
|
|
403
|
+
return [...owners].some((ownership) => (
|
|
404
|
+
ownership.active
|
|
405
|
+
&& !ownership.completed
|
|
406
|
+
&& typeof ownership.reconnect === 'function'
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
|
|
392
410
|
export class HarnessReplyTracker {
|
|
393
411
|
#promptRpcId;
|
|
394
412
|
#lastSeq;
|