dsh-a2a-groups 0.0.0-stage → 0.1.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.
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Turn "some text from outside" into a real turn inside a target Session.
3
+ *
4
+ * This is the one place where the A2A server is allowed to touch DSH. Its job is
5
+ * to reproduce the exact delivery vocabulary the harness already uses for
6
+ * non-human input, so a message that arrives over A2A looks to the receiving
7
+ * Session like every other plugin-sourced message: a left-hand "task message
8
+ * received" card, not a right-aligned bubble the person supposedly typed.
9
+ *
10
+ * Two paths exist because two Host generations exist:
11
+ * 1. `ctx.sessionController.resolveAgent(sessionId)` → `agent.followup|steer|inject`
12
+ * with `source.kind = 'agent-message'`. Preferred: it is the only way to get
13
+ * the non-human presentation and to seat the message in the live Agent.
14
+ * 2. `ctx.sessionController.prompt({requestId, sessionId, mode, content})`.
15
+ * The legacy path, which records an ordinary `user` message. Used when the
16
+ * Host has no live Agent, when the operator asked for `bubble`, or when the
17
+ * resolved Agent exposes no delivery method.
18
+ *
19
+ * Boundaries and non-goals:
20
+ * - It never wraps, prefixes, or rewrites the text. Formatting an envelope is
21
+ * the caller's business (this module has no opinion about A2A semantics).
22
+ * - It never creates a Session. A delivery to an unknown Session fails; creating
23
+ * Sessions implicitly would let one misconfigured peer spawn unbounded agents.
24
+ * - The dedupe ledger is process-local and bounded. It exists to survive a
25
+ * transport retry, not to be a durable inbox; durability belongs to the Session
26
+ * log, which `#alreadyAdmitted()` also consults.
27
+ *
28
+ * @module dsh-a2a/deliver
29
+ */
30
+
31
+ import { createHash } from 'node:crypto';
32
+
33
+ /** The source kind DSH assigns to plugin-originated, non-user input. */
34
+ const NOTICE_SOURCE_KIND = 'agent-message';
35
+
36
+ /** Default window a delivered message counts as a duplicate. */
37
+ const DEFAULT_DEDUPE_WINDOW_MS = 10 * 60 * 1000;
38
+
39
+ /** Ceiling on ledger entries; a long-lived gateway must not grow without bound. */
40
+ const LEDGER_LIMIT = 2000;
41
+
42
+ /** Poll interval for `waitIdle`. */
43
+ const IDLE_POLL_MS = 50;
44
+
45
+ /** A credential-looking field, scrubbed from any text before it is logged. */
46
+ const SECRET_FIELDS = Object.freeze(['token', 'password', 'personKey', 'apiKey', 'secret', 'key']);
47
+
48
+ /**
49
+ * What `deliver()` reports about how the target took the message.
50
+ *
51
+ * `injected` names the admit path that ran, which is also the diagnostic a
52
+ * caller needs: `followup` queued for the next turn, `steer` went into the
53
+ * running step, `inbox` was seated without waking anything, and `duplicate`
54
+ * means the message had already landed. `interrupted` is true only when a steer
55
+ * actually cut into a running turn.
56
+ *
57
+ * @typedef {object} DeliveryReceipt
58
+ * @property {'followup'|'steer'|'inbox'|'duplicate'} injected
59
+ * @property {boolean} interrupted
60
+ */
61
+ /**
62
+ * Build the deliverer.
63
+ *
64
+ * @param {object} options - deliverer options.
65
+ * @param {object} [options.ctx] - Host context; `sessionController` is the only part used.
66
+ * @param {object} [options.logger] - logger with `info` / `warn` / `debug`.
67
+ * @param {object} [options.config] - normalized config; `delivery.*` is read here.
68
+ * @param {() => number} [options.clock] - epoch-ms source, for deterministic tests.
69
+ * @returns {{deliver: (input: object) => Promise<object>, isBusy: (sessionId: string) => boolean, waitIdle: (sessionId: string, ms?: number) => Promise<boolean>, debugState: () => object}} the deliverer.
70
+ */
71
+ export function createDeliverer({ ctx, logger, config, clock, delayFn } = {}) {
72
+ // `delayFn` is injectable so a test can drive waitIdle without sleeping; see the `wait` const below.
73
+ const delivery = config?.delivery ?? {};
74
+ const dedupeWindowMs = Number.isFinite(delivery.dedupeWindowMs) && delivery.dedupeWindowMs > 0
75
+ ? delivery.dedupeWindowMs
76
+ : DEFAULT_DEDUPE_WINDOW_MS;
77
+ const defaultMode = delivery.defaultMode === 'steer' || delivery.defaultMode === 'inbox' ? delivery.defaultMode : 'queue';
78
+ const presentation = delivery.presentation === 'bubble' ? 'bubble' : 'notice';
79
+ const now = typeof clock === 'function' ? clock : Date.now;
80
+ // Injectable so a test can drive `waitIdle` without sleeping. A real timer here made the test depend on the
81
+ // runner's scheduling: on Linux the loop could drain with the timer still pending, which Node reports as
82
+ // cancellation rather than a failed assertion — the least diagnostic outcome possible.
83
+ const wait = typeof delayFn === 'function' ? delayFn : delay;
84
+
85
+ /** @type {Map<string, number>} `${sessionId}\u0000${messageId}` → delivery epoch ms. */
86
+ const ledger = new Map();
87
+
88
+ /** @type {Set<string>} keys currently being delivered; guards concurrent retries. */
89
+ const inflight = new Set();
90
+
91
+ /** @type {Map<string, object>} sessionId → last resolved agent, for `isBusy`. */
92
+ const agents = new Map();
93
+
94
+ // Both services are read through `ctx.get`, never as a bare property.
95
+ //
96
+ // Cordis enforces `inject` for direct property access: `ctx.sessionStore` throws
97
+ // "cannot get property without inject" unless the plugin declared it, and a
98
+ // deliverer must not force every Host to provide a Session store just to mount.
99
+ // `get` asks "is this service available right now" and answers undefined when it
100
+ // is not, which is exactly the contract this module wants — it degrades to the
101
+ // prompt path instead of failing the mount.
102
+ //
103
+ // Found by mounting in a real DSH instance: every unit test injects a fake `ctx`
104
+ // object, and a plain object has no such rule, so the proxy's guard was invisible
105
+ // until a real Host ran it.
106
+ const controller = readService(ctx, 'sessionController');
107
+ const sessionStore = readService(ctx, 'sessionStore');
108
+
109
+ /**
110
+ * Find the live Agent for a Session, tolerating every shape a Host may return.
111
+ *
112
+ * The Host contract is `resolveAgent(sessionId) → {agent} | null`, but a
113
+ * rejected promise or an `{error}` wrapper is also possible; each is folded
114
+ * into "no live agent" so the caller falls through to the prompt path instead
115
+ * of crashing a delivery.
116
+ *
117
+ * @param {string} sessionId - target Session.
118
+ * @returns {Promise<object|null>} the live agent, or null.
119
+ */
120
+ async function resolveAgent(sessionId) {
121
+ if (controller === null || typeof controller !== 'object') return null;
122
+ if (typeof controller.resolveAgent !== 'function') return null;
123
+ let resolved;
124
+ try {
125
+ resolved = await controller.resolveAgent(sessionId);
126
+ } catch (error) {
127
+ warn(`resolveAgent failed for session ${trim(sessionId)}`, error);
128
+ return null;
129
+ }
130
+ if (resolved === null || typeof resolved !== 'object') return null;
131
+ if (resolved.error !== undefined) {
132
+ warn(`resolveAgent reported an error for session ${trim(sessionId)}`, resolved.error);
133
+ return null;
134
+ }
135
+ const agent = resolved.agent;
136
+ if (agent === null || typeof agent !== 'object') return null;
137
+ agents.set(sessionId, agent);
138
+ return agent;
139
+ }
140
+
141
+ /**
142
+ * Whether this exact message already reached the target.
143
+ *
144
+ * Three sources are consulted, cheapest first: our own ledger (survives a
145
+ * transport retry inside one process), the Agent's inbox (a message queued for
146
+ * a turn that has not started), and the Session log (a message whose turn
147
+ * already ran). Checking only one of them would let a retry duplicate at the
148
+ * boundary between "queued" and "logged".
149
+ *
150
+ * @param {object|null} agent - live agent, when one was resolved.
151
+ * @param {string} sessionId - target Session.
152
+ * @param {string} messageId - A2A message id.
153
+ * @returns {boolean} true when the message already landed.
154
+ */
155
+ function alreadyAdmitted(agent, sessionId, messageId) {
156
+ const key = ledgerKey(sessionId, messageId);
157
+ const stamped = ledger.get(key);
158
+ if (stamped !== undefined) {
159
+ if (now() - stamped <= dedupeWindowMs) return true;
160
+ ledger.delete(key);
161
+ }
162
+ if (agent === null) return false;
163
+ if (inboxMatches(agent.inbox, messageId)) return true;
164
+ if (logMatches(agent.session, messageId)) return true;
165
+ return false;
166
+ }
167
+
168
+ /**
169
+ * Record one successful delivery and keep the ledger bounded.
170
+ * @param {string} sessionId - target Session.
171
+ * @param {string} messageId - A2A message id.
172
+ * @returns {void}
173
+ */
174
+ function remember(sessionId, messageId) {
175
+ ledger.delete(ledgerKey(sessionId, messageId));
176
+ ledger.set(ledgerKey(sessionId, messageId), now());
177
+ while (ledger.size > LEDGER_LIMIT) {
178
+ const oldest = ledger.keys().next();
179
+ if (oldest.done === true) break;
180
+ ledger.delete(oldest.value);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Deliver one message into a Session.
186
+ *
187
+ * The order of operations is the whole safety story:
188
+ * 1. reserve the id (a concurrent retry sees a duplicate immediately),
189
+ * 2. check every duplicate source,
190
+ * 3. perform exactly one admit call,
191
+ * 4. commit the ledger entry only after the admit call returned.
192
+ * A throw at step 3 releases the reservation and commits nothing, so a retry
193
+ * is attempted again rather than being swallowed as a duplicate — the failure
194
+ * leaves no trace the next call could mistake for success.
195
+ *
196
+ * @param {object} input - delivery request.
197
+ * @param {string} input.sessionId - target Session id.
198
+ * @param {string} input.text - body to deliver, used verbatim.
199
+ * @param {string} input.messageId - A2A message id; also the retry key.
200
+ * @param {'queue'|'steer'|'inbox'} [input.mode] - delivery mode.
201
+ * @param {object} [input.source] - extra source fields recorded on the message.
202
+ * @param {AbortSignal} [input.signal] - cancellation.
203
+ * @returns {Promise<DeliveryReceipt>} how the target took it.
204
+ * @throws {Error} when the Session is unknown or the Host refuses the delivery.
205
+ */
206
+ async function deliver(input) {
207
+ const sessionId = typeof input?.sessionId === 'string' ? input.sessionId.trim() : '';
208
+ if (sessionId === '') throw new Error('deliver: sessionId is required');
209
+ if (typeof input?.text !== 'string' || input.text === '') throw new Error('deliver: text is required');
210
+ const messageId = typeof input?.messageId === 'string' && input.messageId !== ''
211
+ ? input.messageId
212
+ : `a2a-${createHash('sha256').update(`${sessionId}\u0000${input.text}`).digest('hex').slice(0, 24)}`;
213
+ const mode = normalizeMode(input?.mode, defaultMode);
214
+ const signal = input?.signal;
215
+ signal?.throwIfAborted?.();
216
+
217
+ const key = ledgerKey(sessionId, messageId);
218
+ if (inflight.has(key)) return { injected: 'duplicate', interrupted: false };
219
+ inflight.add(key);
220
+ try {
221
+ const agent = await resolveAgent(sessionId);
222
+ signal?.throwIfAborted?.();
223
+ if (alreadyAdmitted(agent, sessionId, messageId)) {
224
+ remember(sessionId, messageId);
225
+ return { injected: 'duplicate', interrupted: false };
226
+ }
227
+ const useAgentPath = presentation === 'notice'
228
+ && agent !== null
229
+ && typeof agent.followup === 'function';
230
+ const receipt = useAgentPath
231
+ ? await admitAsNotice({ agent, sessionId, messageId, mode, text: input.text, source: input.source, signal })
232
+ : await admitAsPrompt({ sessionId, messageId, mode, text: input.text, signal });
233
+ remember(sessionId, messageId);
234
+ return receipt;
235
+ } finally {
236
+ inflight.delete(key);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Deliver through the Agent API with a non-user source.
242
+ *
243
+ * `followup` queues a next-turn prompt and wakes the driver, `steer` submits
244
+ * into the running step, and `inject` seats the message in the next-step queue
245
+ * without waking anything. Mode is honoured literally here: unlike the
246
+ * reference bus, A2A has no `interrupt` mode, so there is no state-dependent
247
+ * translation to do and no risk of stranding a steered message on an idle
248
+ * Session.
249
+ *
250
+ * @param {object} input - admit inputs.
251
+ * @param {object} input.agent - live agent.
252
+ * @param {string} input.sessionId - target Session.
253
+ * @param {string} input.messageId - A2A message id.
254
+ * @param {string} input.mode - normalized mode.
255
+ * @param {string} input.text - body.
256
+ * @param {object} [input.source] - caller source fields.
257
+ * @param {AbortSignal} [input.signal] - cancellation.
258
+ * @returns {Promise<{injected: string, interrupted: boolean}>} the receipt.
259
+ */
260
+ async function admitAsNotice({ agent, sessionId, messageId, mode, text, source, signal }) {
261
+ const running = agentIsRunning(agent);
262
+ if (mode === 'inbox') {
263
+ const inject = typeof agent.inject === 'function' ? agent.inject : agent.followup;
264
+ await inject.call(agent, createInboundMessage({ text, source: buildSource({ messageId, source }) }));
265
+ await flush(sessionId, agent);
266
+ return { injected: 'inbox', interrupted: false };
267
+ }
268
+ if (mode === 'steer' && typeof agent.steer === 'function') {
269
+ signal?.throwIfAborted?.();
270
+ await agent.steer(createInboundMessage({ text, source: buildSource({ messageId, source }) }));
271
+ await flush(sessionId, agent);
272
+ return { injected: 'steer', interrupted: running };
273
+ }
274
+ signal?.throwIfAborted?.();
275
+ await agent.followup(createInboundMessage({ text, source: buildSource({ messageId, source }) }));
276
+ await flush(sessionId, agent);
277
+ return { injected: 'followup', interrupted: false };
278
+ }
279
+
280
+ /**
281
+ * Deliver through the legacy prompt RPC, which records a `user` message.
282
+ *
283
+ * `requestId` carries the A2A message id so the controller's own retry
284
+ * suppression recognises a repeated delivery even if our ledger was lost.
285
+ * `inbox` has no prompt equivalent, so it degrades to `queue`: the message is
286
+ * still delivered, just woken rather than silently seated.
287
+ *
288
+ * @param {object} input - admit inputs.
289
+ * @param {string} input.sessionId - target Session.
290
+ * @param {string} input.messageId - A2A message id.
291
+ * @param {string} input.mode - normalized mode.
292
+ * @param {string} input.text - body.
293
+ * @param {AbortSignal} [input.signal] - cancellation.
294
+ * @returns {Promise<{injected: string, interrupted: boolean}>} the receipt.
295
+ */
296
+ async function admitAsPrompt({ sessionId, messageId, mode, text, signal }) {
297
+ if (controller === null || typeof controller !== 'object' || typeof controller.prompt !== 'function') {
298
+ throw new Error('deliver: this Host exposes neither a live Agent nor sessionController.prompt()');
299
+ }
300
+ const promptMode = mode === 'steer' ? 'steer' : 'queue';
301
+ await controller.prompt(
302
+ {
303
+ requestId: `a2a:${messageId}`,
304
+ sessionId,
305
+ mode: promptMode,
306
+ content: [{ type: 'text', text }],
307
+ },
308
+ signal,
309
+ );
310
+ return { injected: promptMode, interrupted: promptMode === 'steer' };
311
+ }
312
+
313
+ /**
314
+ * Persist the target Session's buffered events, including the admit splice.
315
+ *
316
+ * Durability is best effort and always after the admit call: the message is
317
+ * already in the live Agent, so reporting a failure here would leave the peer
318
+ * retrying something that already landed. Logged, never thrown.
319
+ *
320
+ * @param {string} sessionId - target Session.
321
+ * @param {object} agent - live agent.
322
+ * @returns {Promise<void>} resolves after the attempt.
323
+ */
324
+ async function flush(sessionId, agent) {
325
+ if (sessionStore === undefined || sessionStore === null) return;
326
+ if (typeof sessionStore.flush !== 'function') return;
327
+ if (agent.session === undefined || agent.session === null) return;
328
+ try {
329
+ await sessionStore.flush(agent.session);
330
+ } catch (error) {
331
+ warn(`delivered to session ${trim(sessionId)} but the Session flush failed`, error);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Whether the target Session is executing a turn right now.
337
+ *
338
+ * Synchronous by contract, so it answers from the last resolved Agent. The
339
+ * live Agent's `status` is authoritative when known; a Session never resolved
340
+ * through this deliverer reports `false`, which is the safe answer for a caller
341
+ * that only wants to decide whether to poll.
342
+ *
343
+ * @param {string} sessionId - target Session.
344
+ * @returns {boolean} true when a turn is running.
345
+ */
346
+ function isBusy(sessionId) {
347
+ if (typeof sessionId !== 'string' || sessionId === '') return false;
348
+ const agent = agents.get(sessionId.trim());
349
+ if (agent === undefined) return false;
350
+ return agentIsRunning(agent);
351
+ }
352
+
353
+ /**
354
+ * Wait until the target Session has no turn running, or the timeout expires.
355
+ *
356
+ * Polling is the honest implementation: the Agent API exposes `status` as a
357
+ * property, not an event, and inventing a promise from it would mean holding a
358
+ * listener on a Host that may never emit one. The interval is short enough to
359
+ * feel immediate and long enough not to spin a core.
360
+ *
361
+ * @param {string} sessionId - target Session.
362
+ * @param {number} [ms] - timeout in ms; default 120 s.
363
+ * @returns {Promise<boolean>} true when the Session went idle, false on timeout.
364
+ */
365
+ async function waitIdle(sessionId, ms = 120000) {
366
+ if (typeof sessionId !== 'string' || sessionId === '') return true;
367
+ const deadline = now() + (Number.isFinite(ms) && ms > 0 ? ms : 120000);
368
+ for (;;) {
369
+ const agent = await resolveAgent(sessionId);
370
+ if (agent === null) return true;
371
+ if (!agentIsRunning(agent)) return true;
372
+ if (now() >= deadline) return false;
373
+ await wait(Math.min(IDLE_POLL_MS, Math.max(1, deadline - now())));
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Expose the deliverer's own state for tests and diagnostics.
379
+ *
380
+ * Deliberately read-only: it hands out counts and copies, never the ledger
381
+ * itself, so a test can assert "a failed delivery left nothing behind" without
382
+ * being able to change the outcome it is measuring.
383
+ *
384
+ * @returns {{delivered: number, inflight: number, sessions: string[]}} a snapshot.
385
+ */
386
+ function debugState() {
387
+ return { delivered: ledger.size, inflight: inflight.size, sessions: [...new Set([...agents.keys()])] };
388
+ }
389
+
390
+ /** Log a warning without leaking any credential that may sit in an error. */
391
+ function warn(message, error) {
392
+ if (logger === null || typeof logger !== 'object' || typeof logger.warn !== 'function') return;
393
+ const detail = error === undefined ? '' : `: ${scrub(errorMessage(error))}`;
394
+ logger.warn(`dsh-a2a: ${scrub(message)}${detail}`);
395
+ }
396
+
397
+ return { deliver, isBusy, waitIdle, debugState };
398
+ }
399
+
400
+ /**
401
+ * Normalize a requested delivery mode.
402
+ * @param {unknown} mode - caller value.
403
+ * @param {string} fallback - configured default.
404
+ * @returns {'queue'|'steer'|'inbox'} the mode to use.
405
+ */
406
+ function normalizeMode(mode, fallback) {
407
+ if (mode === 'steer' || mode === 'inbox' || mode === 'queue') return mode;
408
+ if (fallback === 'steer' || fallback === 'inbox') return fallback;
409
+ return 'queue';
410
+ }
411
+
412
+ /**
413
+ * Build the message `source` the Client uses to pick a presentation.
414
+ *
415
+ * `kind: 'agent-message'` is what makes the Web UI render a non-human card.
416
+ * `form: 'relay'` says another agent addressed this Session rather than the
417
+ * operator. The A2A message id travels as both `busMessageId` (the field DSH's
418
+ * own plugins use for retry recognition) and `a2aMessageId` (so a future reader
419
+ * knows where it came from).
420
+ *
421
+ * @param {{messageId: string, source?: object}} input - identity inputs.
422
+ * @returns {object} a frozen-looking plain source value.
423
+ */
424
+ function buildSource({ messageId, source }) {
425
+ const extra = source !== null && typeof source === 'object' && !Array.isArray(source) ? source : {};
426
+ return {
427
+ ...extra,
428
+ kind: NOTICE_SOURCE_KIND,
429
+ form: 'relay',
430
+ busMessageId: messageId,
431
+ a2aMessageId: messageId,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Build one identified user-role message in the shape the Agent API accepts.
437
+ *
438
+ * This mirrors DSH's own `createUserMessage()`: a fresh id, a text content part,
439
+ * and a source. A profile plugin cannot import that helper (a bare
440
+ * `@deepseek-ai/*` specifier does not resolve outside the installation), so the
441
+ * value shape is reproduced against the documented API instead.
442
+ *
443
+ * @param {{text: string, source: object}} input - body and producer source.
444
+ * @returns {object} the message value.
445
+ */
446
+ function createInboundMessage({ text, source }) {
447
+ return structuredClone({
448
+ id: globalThis.crypto?.randomUUID?.() ?? `a2a-${Date.now()}-${Math.random().toString(16).slice(2)}`,
449
+ role: 'user',
450
+ content: [{ type: 'text', text }],
451
+ source,
452
+ });
453
+ }
454
+
455
+ /**
456
+ * Whether an inbox queue already holds this message.
457
+ * @param {object|undefined} inbox - `{nextTurn?, nextStep?}`.
458
+ * @param {string} messageId - A2A message id.
459
+ * @returns {boolean} true when found.
460
+ */
461
+ function inboxMatches(inbox, messageId) {
462
+ if (inbox === null || typeof inbox !== 'object') return false;
463
+ for (const queue of [inbox.nextTurn, inbox.nextStep]) {
464
+ if (!Array.isArray(queue)) continue;
465
+ if (queue.some((message) => sourceMatches(message?.source, messageId))) return true;
466
+ }
467
+ return false;
468
+ }
469
+
470
+ /**
471
+ * Whether the Session's durable event log already holds this message.
472
+ * @param {object|undefined} session - Session with optional `snapshotEvents()`.
473
+ * @param {string} messageId - A2A message id.
474
+ * @returns {boolean} true when found.
475
+ */
476
+ function logMatches(session, messageId) {
477
+ if (session === null || typeof session !== 'object') return false;
478
+ if (typeof session.snapshotEvents !== 'function') return false;
479
+ let events;
480
+ try {
481
+ events = session.snapshotEvents();
482
+ } catch {
483
+ return false;
484
+ }
485
+ if (!Array.isArray(events)) return false;
486
+ for (let index = events.length - 1; index >= 0; index -= 1) {
487
+ const event = events[index];
488
+ if (event?.type !== 'user/message') continue;
489
+ if (sourceMatches(event.data?.source ?? event.data, messageId)) return true;
490
+ }
491
+ return false;
492
+ }
493
+
494
+ /**
495
+ * Whether a message source identifies the given A2A message id.
496
+ * @param {unknown} source - candidate source value.
497
+ * @param {string} messageId - A2A message id.
498
+ * @returns {boolean} true when it matches on either id field.
499
+ */
500
+ function sourceMatches(source, messageId) {
501
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) return false;
502
+ return source.busMessageId === messageId || source.a2aMessageId === messageId;
503
+ }
504
+
505
+ /**
506
+ * Whether the target Agent is executing a turn right now.
507
+ *
508
+ * The Agent's own `status` is authoritative; the Session row is a snapshot that
509
+ * can already be stale by the time a message arrives.
510
+ * @param {object|undefined} agent - live agent.
511
+ * @returns {boolean} true when a turn is running.
512
+ */
513
+ function agentIsRunning(agent) {
514
+ if (agent?.status === 'running') return true;
515
+ if (agent?.status === 'idle' || agent?.status === 'cold') return false;
516
+ return agent?.running === true;
517
+ }
518
+
519
+ /**
520
+ * Compose the ledger key. The message id alone is not enough: two Sessions may
521
+ * legitimately receive the same A2A message id from two different peers.
522
+ * @param {string} sessionId - target Session.
523
+ * @param {string} messageId - A2A message id.
524
+ * @returns {string} the composite key.
525
+ */
526
+ function ledgerKey(sessionId, messageId) {
527
+ return `${sessionId}\u0000${messageId}`;
528
+ }
529
+
530
+ /** Shorten an id for a log line. */
531
+ function trim(value) {
532
+ const text = String(value ?? '');
533
+ return text.length <= 24 ? text : `${text.slice(0, 24)}…`;
534
+ }
535
+
536
+ /** Render an error as a message string without a stack. */
537
+ function errorMessage(error) {
538
+ if (error instanceof Error) return error.message;
539
+ return String(error ?? 'unknown error');
540
+ }
541
+
542
+ /**
543
+ * Remove credential-looking values from a log line.
544
+ *
545
+ * The same scrubber as `wire.redactAll`, inlined so this module imports nothing
546
+ * but `node:crypto`: a delivery failure must not be the thing that drags the
547
+ * whole wire module (and its store) into a test that only wants to check ids.
548
+ * @param {string} text - candidate log text.
549
+ * @returns {string} text with `key=value` secrets masked.
550
+ */
551
+ function scrub(text) {
552
+ let result = String(text ?? '');
553
+ for (const field of SECRET_FIELDS) {
554
+ const pattern = new RegExp(`(${field}\\s*[:=]\\s*)([^\\s,;)"']+)`, 'gi');
555
+ result = result.replace(pattern, (_match, prefix, secret) => `${prefix}sha256:${createHash('sha256').update(secret).digest('hex').slice(0, 8)}`);
556
+ }
557
+ return result;
558
+ }
559
+
560
+ /** Resolve after `ms` milliseconds. */
561
+ function delay(ms) {
562
+ return new Promise((resolve) => {
563
+ const timer = setTimeout(resolve, ms);
564
+ // Do not keep the process alive just for an idle poll.
565
+ timer.unref?.();
566
+ });
567
+ }
568
+
569
+ /**
570
+ * Read one Host service without tripping Cordis's `inject` guard.
571
+ *
572
+ * Two reads, in this order, for one reason: `ctx.get(name)` is the reflective
573
+ * read and is always safe, but a plain test double has no `get` and simply holds
574
+ * the service as a property. Reading the property directly is what Cordis
575
+ * forbids for an *undeclared* service, so it is attempted only through a
576
+ * try/catch — a real Host throws there and this falls back to "absent", which is
577
+ * the same answer the deliverer wants.
578
+ *
579
+ * @param {object} ctx - plugin context.
580
+ * @param {string} name - service key.
581
+ * @returns {any} the service, or undefined.
582
+ */
583
+ function readService(ctx, name) {
584
+ if (ctx === null || typeof ctx !== 'object') return undefined;
585
+ if (typeof ctx.get === 'function') {
586
+ try {
587
+ const viaGet = ctx.get(name);
588
+ if (viaGet !== undefined && viaGet !== null) return viaGet;
589
+ } catch {
590
+ // Fall through to the property read.
591
+ }
592
+ }
593
+ try {
594
+ const direct = ctx[name];
595
+ return direct === null ? undefined : direct;
596
+ } catch {
597
+ // Cordis's inject guard: the plugin did not declare this service, so it is
598
+ // not available to it. Absent is the correct reading.
599
+ return undefined;
600
+ }
601
+ }