@botcord/daemon 0.2.86 → 0.2.87
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/dist/cloud-daemon.js +6 -1
- package/dist/daemon.d.ts +3 -1
- package/dist/daemon.js +11 -3
- package/dist/gateway/dispatcher.d.ts +16 -0
- package/dist/gateway/dispatcher.js +25 -1
- package/dist/provision.js +8 -1
- package/dist/skill-index.d.ts +1 -0
- package/dist/skill-index.js +50 -14
- package/dist/turn-text.js +38 -1
- package/package.json +1 -1
- package/src/__tests__/dispatcher-reply-to.test.ts +61 -0
- package/src/__tests__/skill-index.test.ts +39 -0
- package/src/__tests__/turn-text.test.ts +121 -0
- package/src/cloud-daemon.ts +5 -1
- package/src/daemon.ts +11 -2
- package/src/gateway/dispatcher.ts +26 -1
- package/src/provision.ts +9 -1
- package/src/skill-index.ts +55 -15
- package/src/turn-text.ts +51 -1
package/dist/cloud-daemon.js
CHANGED
|
@@ -158,19 +158,24 @@ export async function startCloudDaemon(opts) {
|
|
|
158
158
|
});
|
|
159
159
|
};
|
|
160
160
|
const installedAgentIds = new Set();
|
|
161
|
+
const runtimeByAgentId = new Map();
|
|
161
162
|
let controlChannel = null;
|
|
162
163
|
const pushInstalledAgentSkillSnapshot = (agentId, reason) => {
|
|
163
164
|
if (!controlChannel)
|
|
164
165
|
return;
|
|
165
|
-
const
|
|
166
|
+
const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
|
|
167
|
+
const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
|
|
166
168
|
logger.info("cloud control-channel: agent_skill_snapshot pushed", {
|
|
167
169
|
agentId,
|
|
170
|
+
runtime,
|
|
168
171
|
reason,
|
|
169
172
|
ok: pushed,
|
|
170
173
|
});
|
|
171
174
|
};
|
|
172
175
|
const onAgentInstalled = (info) => {
|
|
173
176
|
installedAgentIds.add(info.agentId);
|
|
177
|
+
if (info.runtime)
|
|
178
|
+
runtimeByAgentId.set(info.agentId, info.runtime);
|
|
174
179
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
175
180
|
if (info.hubUrl)
|
|
176
181
|
hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
package/dist/daemon.d.ts
CHANGED
|
@@ -65,7 +65,9 @@ export interface RuntimeSnapshotSink {
|
|
|
65
65
|
* or wait for the next daemon restart). Exported for unit tests.
|
|
66
66
|
*/
|
|
67
67
|
export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink, liveSnapshot?: GatewayRuntimeSnapshot): boolean;
|
|
68
|
-
export declare function pushAgentSkillSnapshot(sink: RuntimeSnapshotSink, agentId: string
|
|
68
|
+
export declare function pushAgentSkillSnapshot(sink: RuntimeSnapshotSink, agentId: string, opts?: {
|
|
69
|
+
runtime?: string;
|
|
70
|
+
}): boolean;
|
|
69
71
|
/** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
|
|
70
72
|
export interface DaemonRuntimeOptions {
|
|
71
73
|
config: DaemonConfig;
|
package/dist/daemon.js
CHANGED
|
@@ -165,8 +165,8 @@ export function pushRuntimeSnapshot(sink, liveSnapshot) {
|
|
|
165
165
|
}
|
|
166
166
|
return ok;
|
|
167
167
|
}
|
|
168
|
-
export function pushAgentSkillSnapshot(sink, agentId) {
|
|
169
|
-
const snap = collectAgentSkillSnapshot(agentId);
|
|
168
|
+
export function pushAgentSkillSnapshot(sink, agentId, opts = {}) {
|
|
169
|
+
const snap = collectAgentSkillSnapshot(agentId, opts);
|
|
170
170
|
const ok = sink.send({
|
|
171
171
|
id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
172
172
|
type: "agent_skill_snapshot",
|
|
@@ -372,6 +372,12 @@ export async function startDaemon(opts) {
|
|
|
372
372
|
// next room-context fetch re-loads the BotCordClient against the new
|
|
373
373
|
// credential file.
|
|
374
374
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
375
|
+
if (info.runtime) {
|
|
376
|
+
agentRuntimes[info.agentId] = {
|
|
377
|
+
...(agentRuntimes[info.agentId] ?? {}),
|
|
378
|
+
runtime: info.runtime,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
375
381
|
if (info.hubUrl)
|
|
376
382
|
hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
377
383
|
if (info.displayName)
|
|
@@ -501,9 +507,11 @@ export async function startDaemon(opts) {
|
|
|
501
507
|
ok: pushed,
|
|
502
508
|
});
|
|
503
509
|
for (const agentId of agentIds) {
|
|
504
|
-
const
|
|
510
|
+
const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
|
|
511
|
+
const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
|
|
505
512
|
logger.info("control-channel: initial agent_skill_snapshot push", {
|
|
506
513
|
agentId,
|
|
514
|
+
runtime,
|
|
507
515
|
ok: skillsPushed,
|
|
508
516
|
});
|
|
509
517
|
}
|
|
@@ -2,6 +2,22 @@ import type { GatewayLogger } from "./log.js";
|
|
|
2
2
|
import { type SessionStore } from "./session-store.js";
|
|
3
3
|
import { type TranscriptWriter } from "./transcript.js";
|
|
4
4
|
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRecoveryContextBuilder, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Pick the canonical reply_to value to attach to outbound replies for a given
|
|
7
|
+
* inbound `GatewayInboundMessage`. Priority:
|
|
8
|
+
*
|
|
9
|
+
* 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
|
|
10
|
+
* receipts and threaded replies point at the original target.
|
|
11
|
+
* 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
|
|
12
|
+
* This is the canonical form the hub stores in `reply_to_msg_id`.
|
|
13
|
+
* 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
|
|
14
|
+
* stamps on every inbound. The hub accepts this form via
|
|
15
|
+
* `_load_reply_target`'s prefix-based discriminator, but emitting it is
|
|
16
|
+
* lossy because the hub then has to resolve it back to msg_id.
|
|
17
|
+
*
|
|
18
|
+
* Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
|
|
19
|
+
*/
|
|
20
|
+
export declare function pickReplyToTarget(msg: GatewayInboundMessage): string;
|
|
5
21
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
6
22
|
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
7
23
|
/** Constructor options for `Dispatcher`. */
|
|
@@ -143,6 +143,30 @@ function buildRuntimeRecoveryPrompt(args) {
|
|
|
143
143
|
args.userTurn,
|
|
144
144
|
].join("\n");
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Pick the canonical reply_to value to attach to outbound replies for a given
|
|
148
|
+
* inbound `GatewayInboundMessage`. Priority:
|
|
149
|
+
*
|
|
150
|
+
* 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
|
|
151
|
+
* receipts and threaded replies point at the original target.
|
|
152
|
+
* 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
|
|
153
|
+
* This is the canonical form the hub stores in `reply_to_msg_id`.
|
|
154
|
+
* 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
|
|
155
|
+
* stamps on every inbound. The hub accepts this form via
|
|
156
|
+
* `_load_reply_target`'s prefix-based discriminator, but emitting it is
|
|
157
|
+
* lossy because the hub then has to resolve it back to msg_id.
|
|
158
|
+
*
|
|
159
|
+
* Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
|
|
160
|
+
*/
|
|
161
|
+
export function pickReplyToTarget(msg) {
|
|
162
|
+
if (msg.replyTo)
|
|
163
|
+
return msg.replyTo;
|
|
164
|
+
const raw = msg.raw;
|
|
165
|
+
const envMsgId = raw && typeof raw.envelope?.msg_id === "string" && raw.envelope.msg_id
|
|
166
|
+
? raw.envelope.msg_id
|
|
167
|
+
: null;
|
|
168
|
+
return envMsgId ?? msg.id;
|
|
169
|
+
}
|
|
146
170
|
/**
|
|
147
171
|
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
148
172
|
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
@@ -1775,7 +1799,7 @@ export class Dispatcher {
|
|
|
1775
1799
|
return { ok: true };
|
|
1776
1800
|
}
|
|
1777
1801
|
providerReplyTo(msg) {
|
|
1778
|
-
return msg
|
|
1802
|
+
return pickReplyToTarget(msg);
|
|
1779
1803
|
}
|
|
1780
1804
|
emitInbound(turnId, msg) {
|
|
1781
1805
|
if (!this.transcript.enabled)
|
package/dist/provision.js
CHANGED
|
@@ -22,6 +22,11 @@ import { discoverRuntimeModelCatalog } from "./runtime-models.js";
|
|
|
22
22
|
import { collectAgentSkillSnapshot } from "./skill-index.js";
|
|
23
23
|
import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
|
|
24
24
|
import { handleCloudGatewayRuntimeInbound, } from "./cloud-gateway-runtime.js";
|
|
25
|
+
function runtimeForLoadedAgent(gateway, agentId) {
|
|
26
|
+
return gateway.listManagedRoutes()
|
|
27
|
+
.find((route) => route.match?.accountId === agentId)
|
|
28
|
+
?.runtime;
|
|
29
|
+
}
|
|
25
30
|
/**
|
|
26
31
|
* Build a dispatcher function that routes a `ControlFrame` to the right
|
|
27
32
|
* handler. Returned function signature matches
|
|
@@ -334,9 +339,11 @@ export function createProvisioner(opts) {
|
|
|
334
339
|
},
|
|
335
340
|
};
|
|
336
341
|
}
|
|
337
|
-
const
|
|
342
|
+
const runtime = runtimeForLoadedAgent(gateway, params.agentId);
|
|
343
|
+
const result = collectAgentSkillSnapshot(params.agentId, { runtime });
|
|
338
344
|
daemonLog.debug("list_agent_skills", {
|
|
339
345
|
agentId: params.agentId,
|
|
346
|
+
runtime,
|
|
340
347
|
count: result.skills.length,
|
|
341
348
|
});
|
|
342
349
|
return { ok: true, result };
|
package/dist/skill-index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface AgentSkillSnapshot {
|
|
|
19
19
|
export interface SkillIndexOptions {
|
|
20
20
|
extraDirs?: string[];
|
|
21
21
|
includeGlobal?: boolean;
|
|
22
|
+
runtime?: string;
|
|
22
23
|
}
|
|
23
24
|
export declare function defaultSkillDirs(agentId: string, opts?: SkillIndexOptions): Array<{
|
|
24
25
|
dir: string;
|
package/dist/skill-index.js
CHANGED
|
@@ -7,24 +7,29 @@ const MAX_DESCRIPTION_CHARS = 260;
|
|
|
7
7
|
const MAX_SKILL_MD_READ_CHARS = 8192;
|
|
8
8
|
export function defaultSkillDirs(agentId, opts = {}) {
|
|
9
9
|
const includeGlobal = opts.includeGlobal !== false;
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
const agentClaude = {
|
|
11
|
+
dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
|
|
12
|
+
source: "agent-claude",
|
|
13
|
+
};
|
|
14
|
+
const agentCodex = {
|
|
15
|
+
dir: path.join(agentCodexHomeDir(agentId), "skills"),
|
|
16
|
+
source: "agent-codex",
|
|
17
|
+
};
|
|
18
|
+
const dirs = runtimeFamily(opts.runtime) === "codex"
|
|
19
|
+
? [agentCodex, agentClaude]
|
|
20
|
+
: [agentClaude, agentCodex];
|
|
20
21
|
if (includeGlobal) {
|
|
21
|
-
|
|
22
|
+
const globalClaude = { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" };
|
|
23
|
+
const globalCodex = { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" };
|
|
24
|
+
dirs.push(...(runtimeFamily(opts.runtime) === "codex"
|
|
25
|
+
? [globalCodex, globalClaude]
|
|
26
|
+
: [globalClaude, globalCodex]));
|
|
22
27
|
}
|
|
23
28
|
const envDirs = parseSkillDirsEnv(process.env.BOTCORD_SKILL_DIRS);
|
|
24
29
|
for (const dir of [...envDirs, ...(opts.extraDirs ?? [])]) {
|
|
25
30
|
dirs.push({ dir, source: "external" });
|
|
26
31
|
}
|
|
27
|
-
return dedupeDirs(dirs);
|
|
32
|
+
return dedupeDirs(expandSkillRoots(dirs));
|
|
28
33
|
}
|
|
29
34
|
export function scanSoftSkills(agentId, opts = {}) {
|
|
30
35
|
const byName = new Map();
|
|
@@ -63,7 +68,7 @@ export function scanSoftSkills(agentId, opts = {}) {
|
|
|
63
68
|
description: parsed.description,
|
|
64
69
|
mtimeMs: st.mtimeMs,
|
|
65
70
|
};
|
|
66
|
-
if (!existing || priority(root.source) < priority(existing.source)) {
|
|
71
|
+
if (!existing || priority(root.source, opts.runtime) < priority(existing.source, opts.runtime)) {
|
|
67
72
|
byName.set(entry.name, entry);
|
|
68
73
|
}
|
|
69
74
|
}
|
|
@@ -156,7 +161,38 @@ function dedupeDirs(dirs) {
|
|
|
156
161
|
}
|
|
157
162
|
return out;
|
|
158
163
|
}
|
|
159
|
-
function
|
|
164
|
+
function expandSkillRoots(dirs) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const entry of dirs) {
|
|
167
|
+
out.push(entry);
|
|
168
|
+
if (entry.source.includes("codex")) {
|
|
169
|
+
out.push({ dir: path.join(entry.dir, ".system"), source: entry.source });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
function runtimeFamily(runtime) {
|
|
175
|
+
if (runtime === "codex")
|
|
176
|
+
return "codex";
|
|
177
|
+
if (runtime === "claude-code")
|
|
178
|
+
return "claude";
|
|
179
|
+
return "other";
|
|
180
|
+
}
|
|
181
|
+
function priority(source, runtime) {
|
|
182
|
+
if (runtimeFamily(runtime) === "codex") {
|
|
183
|
+
switch (source) {
|
|
184
|
+
case "agent-codex":
|
|
185
|
+
return 0;
|
|
186
|
+
case "global-codex":
|
|
187
|
+
return 1;
|
|
188
|
+
case "agent-claude":
|
|
189
|
+
return 2;
|
|
190
|
+
case "global-claude":
|
|
191
|
+
return 3;
|
|
192
|
+
default:
|
|
193
|
+
return 4;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
160
196
|
switch (source) {
|
|
161
197
|
case "agent-claude":
|
|
162
198
|
return 0;
|
package/dist/turn-text.js
CHANGED
|
@@ -118,6 +118,39 @@ function entryText(e) {
|
|
|
118
118
|
return e.envelope.payload.text;
|
|
119
119
|
return "";
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Format the inline quote-reply context line that prefixes a message body
|
|
123
|
+
* when the inbound envelope replies to another message. Single layer — we
|
|
124
|
+
* never render a quote-of-a-quote chain. Returns `null` when the source
|
|
125
|
+
* carries no reply_preview, so the caller can skip emitting an empty line.
|
|
126
|
+
*
|
|
127
|
+
* Both sender label and preview body are sanitized; the preview is hard-
|
|
128
|
+
* capped at 120 chars to mirror the backend truncation.
|
|
129
|
+
*/
|
|
130
|
+
function formatReplyQuoteLine(raw) {
|
|
131
|
+
if (!raw || typeof raw !== "object")
|
|
132
|
+
return null;
|
|
133
|
+
const rp = raw.reply_preview;
|
|
134
|
+
if (!rp || typeof rp !== "object")
|
|
135
|
+
return null;
|
|
136
|
+
if (rp.deleted === true) {
|
|
137
|
+
return "[quoting (deleted message)]";
|
|
138
|
+
}
|
|
139
|
+
const senderRaw = typeof rp.sender_display_name === "string" && rp.sender_display_name
|
|
140
|
+
? rp.sender_display_name
|
|
141
|
+
: typeof rp.sender_id === "string" && rp.sender_id
|
|
142
|
+
? rp.sender_id
|
|
143
|
+
: "unknown";
|
|
144
|
+
const sender = sanitizeSenderName(senderRaw);
|
|
145
|
+
const previewRaw = typeof rp.text_preview === "string" ? rp.text_preview : "";
|
|
146
|
+
const previewClean = sanitizeUntrustedContent(previewRaw)
|
|
147
|
+
.replace(/[\r\n]+/g, " ")
|
|
148
|
+
.slice(0, 120);
|
|
149
|
+
if (!previewClean) {
|
|
150
|
+
return `[quoting ${sender}]`;
|
|
151
|
+
}
|
|
152
|
+
return `[quoting ${sender}: "${previewClean}"]`;
|
|
153
|
+
}
|
|
121
154
|
function formatRoomContext(raw, fallback) {
|
|
122
155
|
const r = raw && typeof raw === "object" ? raw : {};
|
|
123
156
|
const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
|
|
@@ -206,11 +239,13 @@ export function composeBotCordUserTurn(msg) {
|
|
|
206
239
|
"they can decide whether to accept or reject it. Include the sender's " +
|
|
207
240
|
"agent ID and any message they attached.]"
|
|
208
241
|
: null;
|
|
242
|
+
const quoteLine = formatReplyQuoteLine(msg.raw);
|
|
209
243
|
const lines = [
|
|
210
244
|
headerFields.join(" | "),
|
|
211
245
|
...formatScheduleContext(msg.raw),
|
|
212
246
|
...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
|
|
213
247
|
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
248
|
+
...(quoteLine ? [quoteLine] : []),
|
|
214
249
|
trimmed,
|
|
215
250
|
`</${tag}>`,
|
|
216
251
|
"",
|
|
@@ -256,7 +291,9 @@ function composeBatchedTurn(msg, batch) {
|
|
|
256
291
|
// non-owner. Still sanitize defensively.
|
|
257
292
|
const safeBody = sanitizeUntrustedContent(raw);
|
|
258
293
|
const tag = kind === "human" ? "human-message" : "agent-message";
|
|
259
|
-
|
|
294
|
+
const quoteLine = formatReplyQuoteLine(entry);
|
|
295
|
+
const inner = quoteLine ? `${quoteLine}\n${safeBody}` : safeBody;
|
|
296
|
+
blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${inner}\n</${tag}>`);
|
|
260
297
|
if (envelopeType === "contact_request") {
|
|
261
298
|
contactRequestSenders.push(safeLabel);
|
|
262
299
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pickReplyToTarget } from "../gateway/dispatcher.js";
|
|
3
|
+
import type { GatewayInboundMessage } from "../gateway/index.js";
|
|
4
|
+
|
|
5
|
+
function makeMsg(partial: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
|
|
6
|
+
return {
|
|
7
|
+
id: partial.id ?? "h_abc123",
|
|
8
|
+
channel: partial.channel ?? "botcord",
|
|
9
|
+
accountId: partial.accountId ?? "ag_me",
|
|
10
|
+
conversation: partial.conversation ?? { id: "rm_room", kind: "group" },
|
|
11
|
+
sender: partial.sender ?? { id: "ag_alice", kind: "agent" },
|
|
12
|
+
text: partial.text ?? "hi",
|
|
13
|
+
raw: partial.raw ?? null,
|
|
14
|
+
receivedAt: partial.receivedAt ?? Date.now(),
|
|
15
|
+
mentioned: partial.mentioned ?? false,
|
|
16
|
+
replyTo: partial.replyTo ?? null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("pickReplyToTarget", () => {
|
|
21
|
+
it("returns msg.replyTo when the inbound was already a reply (chain semantics)", () => {
|
|
22
|
+
const result = pickReplyToTarget(
|
|
23
|
+
makeMsg({
|
|
24
|
+
replyTo: "11111111-2222-3333-4444-555555555555",
|
|
25
|
+
id: "h_inbound",
|
|
26
|
+
raw: { envelope: { msg_id: "ignored-because-chain-takes-priority" } },
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
expect(result).toBe("11111111-2222-3333-4444-555555555555");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns envelope.msg_id (canonical UUID) when present and not chained", () => {
|
|
33
|
+
const result = pickReplyToTarget(
|
|
34
|
+
makeMsg({
|
|
35
|
+
id: "h_inbound",
|
|
36
|
+
raw: { envelope: { msg_id: "11111111-2222-3333-4444-555555555555" } },
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
expect(result).toBe("11111111-2222-3333-4444-555555555555");
|
|
40
|
+
expect(result).not.toMatch(/^h_/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to hub_msg_id when envelope.msg_id is missing", () => {
|
|
44
|
+
const result = pickReplyToTarget(
|
|
45
|
+
makeMsg({ id: "h_inbound", raw: null }),
|
|
46
|
+
);
|
|
47
|
+
// Hub is lenient (accepts h_* via _load_reply_target), so this is still
|
|
48
|
+
// resolvable on the wire — but the helper should clearly mark the fallback.
|
|
49
|
+
expect(result).toBe("h_inbound");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("ignores non-string envelope.msg_id and falls back to hub_msg_id", () => {
|
|
53
|
+
const result = pickReplyToTarget(
|
|
54
|
+
makeMsg({
|
|
55
|
+
id: "h_inbound",
|
|
56
|
+
raw: { envelope: { msg_id: 42 as unknown as string } },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
expect(result).toBe("h_inbound");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -62,6 +62,45 @@ describe("skill snapshots", () => {
|
|
|
62
62
|
expect(snapshot.probedAt).toBeGreaterThan(0);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("scans Codex .system skills and prefers Codex copies for Codex agents", () => {
|
|
66
|
+
const agentId = "ag_codex_system";
|
|
67
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "shared", "Claude copy");
|
|
68
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "shared", "Codex copy");
|
|
69
|
+
writeSkill(
|
|
70
|
+
path.join(agentCodexHomeDir(agentId), "skills", ".system"),
|
|
71
|
+
"agent-system",
|
|
72
|
+
"Agent Codex system skill",
|
|
73
|
+
);
|
|
74
|
+
writeSkill(
|
|
75
|
+
path.join(tmpDir, ".codex", "skills", ".system"),
|
|
76
|
+
"imagegen",
|
|
77
|
+
"Codex global system skill",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
|
|
81
|
+
expect(codexScanned.map((s) => s.name).sort()).toEqual([
|
|
82
|
+
"agent-system",
|
|
83
|
+
"imagegen",
|
|
84
|
+
"shared",
|
|
85
|
+
]);
|
|
86
|
+
expect(codexScanned.find((s) => s.name === "shared")).toMatchObject({
|
|
87
|
+
source: "agent-codex",
|
|
88
|
+
description: "Codex copy",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
|
|
92
|
+
expect(claudeScanned.find((s) => s.name === "shared")).toMatchObject({
|
|
93
|
+
source: "agent-claude",
|
|
94
|
+
description: "Claude copy",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
|
|
98
|
+
expect(snapshot.skills.find((s) => s.name === "agent-system")?.source)
|
|
99
|
+
.toBe("workspace");
|
|
100
|
+
expect(snapshot.skills.find((s) => s.name === "imagegen")?.source)
|
|
101
|
+
.toBe("runtime-global");
|
|
102
|
+
});
|
|
103
|
+
|
|
65
104
|
it("returns complete snapshots while keeping the prompt soft index capped", () => {
|
|
66
105
|
const agentId = "ag_manyskills";
|
|
67
106
|
const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
@@ -370,3 +370,124 @@ describe("composeBotCordUserTurn", () => {
|
|
|
370
370
|
expect(headerLines.length).toBe(1);
|
|
371
371
|
});
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
describe("composeBotCordUserTurn quote-reply", () => {
|
|
375
|
+
it("inserts a [quoting …] line above the body when reply_preview is present", () => {
|
|
376
|
+
const out = composeBotCordUserTurn(
|
|
377
|
+
makeMessage({
|
|
378
|
+
text: "agreed, ship it",
|
|
379
|
+
sender: { id: "ag_alice", name: "Alice", kind: "agent" },
|
|
380
|
+
raw: {
|
|
381
|
+
reply_preview: {
|
|
382
|
+
msg_id: "h_orig",
|
|
383
|
+
sender_id: "ag_bob",
|
|
384
|
+
sender_display_name: "Bob",
|
|
385
|
+
text_preview: "We should ship the feature next sprint",
|
|
386
|
+
topic_id: null,
|
|
387
|
+
deleted: false,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
|
|
393
|
+
expect(out).toContain('[quoting Bob: "We should ship the feature next sprint"]');
|
|
394
|
+
expect(out).toContain("agreed, ship it");
|
|
395
|
+
// Quote line precedes body inside the tag block.
|
|
396
|
+
const quoteIdx = out.indexOf("[quoting Bob");
|
|
397
|
+
const bodyIdx = out.indexOf("agreed, ship it");
|
|
398
|
+
expect(quoteIdx).toBeGreaterThan(-1);
|
|
399
|
+
expect(quoteIdx).toBeLessThan(bodyIdx);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("renders a tombstone line when the quote target was deleted", () => {
|
|
403
|
+
const out = composeBotCordUserTurn(
|
|
404
|
+
makeMessage({
|
|
405
|
+
text: "RE: that thing",
|
|
406
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
407
|
+
raw: {
|
|
408
|
+
reply_preview: {
|
|
409
|
+
msg_id: "h_gone",
|
|
410
|
+
sender_id: null,
|
|
411
|
+
sender_display_name: null,
|
|
412
|
+
text_preview: null,
|
|
413
|
+
topic_id: null,
|
|
414
|
+
deleted: true,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
expect(out).toContain("[quoting (deleted message)]");
|
|
420
|
+
expect(out).toContain("RE: that thing");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("falls back to sender_id when display name is missing", () => {
|
|
424
|
+
const out = composeBotCordUserTurn(
|
|
425
|
+
makeMessage({
|
|
426
|
+
text: "ack",
|
|
427
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
428
|
+
raw: {
|
|
429
|
+
reply_preview: {
|
|
430
|
+
msg_id: "h_orig",
|
|
431
|
+
sender_id: "ag_bob",
|
|
432
|
+
sender_display_name: null,
|
|
433
|
+
text_preview: "hi",
|
|
434
|
+
topic_id: null,
|
|
435
|
+
deleted: false,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
expect(out).toContain('[quoting ag_bob: "hi"]');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("emits no quote line when reply_preview is absent (regression guard)", () => {
|
|
444
|
+
const out = composeBotCordUserTurn(
|
|
445
|
+
makeMessage({
|
|
446
|
+
text: "just a normal message",
|
|
447
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
expect(out).not.toContain("[quoting");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("renders per-entry quote lines in a batched turn", () => {
|
|
454
|
+
const batchedRaw = {
|
|
455
|
+
batch: [
|
|
456
|
+
{
|
|
457
|
+
hub_msg_id: "h_1",
|
|
458
|
+
text: "first reply",
|
|
459
|
+
envelope: { from: "ag_alice", type: "message" },
|
|
460
|
+
source_type: "agent",
|
|
461
|
+
reply_preview: {
|
|
462
|
+
msg_id: "h_orig1",
|
|
463
|
+
sender_id: "ag_bob",
|
|
464
|
+
sender_display_name: "Bob",
|
|
465
|
+
text_preview: "the plan",
|
|
466
|
+
topic_id: null,
|
|
467
|
+
deleted: false,
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
hub_msg_id: "h_2",
|
|
472
|
+
text: "second reply (no quote)",
|
|
473
|
+
envelope: { from: "ag_alice", type: "message" },
|
|
474
|
+
source_type: "agent",
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
};
|
|
478
|
+
const out = composeBotCordUserTurn(
|
|
479
|
+
makeMessage({
|
|
480
|
+
text: "ignored — batch path reads raw.batch",
|
|
481
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
482
|
+
raw: batchedRaw,
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
expect(out).toContain("[BotCord Messages (2 new)]");
|
|
486
|
+
expect(out).toContain('[quoting Bob: "the plan"]');
|
|
487
|
+
expect(out).toContain("first reply");
|
|
488
|
+
expect(out).toContain("second reply (no quote)");
|
|
489
|
+
// The second entry has no quote line.
|
|
490
|
+
const quoteCount = (out.match(/\[quoting /g) || []).length;
|
|
491
|
+
expect(quoteCount).toBe(1);
|
|
492
|
+
});
|
|
493
|
+
});
|
package/src/cloud-daemon.ts
CHANGED
|
@@ -235,12 +235,15 @@ export async function startCloudDaemon(
|
|
|
235
235
|
};
|
|
236
236
|
|
|
237
237
|
const installedAgentIds = new Set<string>();
|
|
238
|
+
const runtimeByAgentId = new Map<string, string>();
|
|
238
239
|
let controlChannel: ControlChannel | null = null;
|
|
239
240
|
const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
|
|
240
241
|
if (!controlChannel) return;
|
|
241
|
-
const
|
|
242
|
+
const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
|
|
243
|
+
const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
|
|
242
244
|
logger.info("cloud control-channel: agent_skill_snapshot pushed", {
|
|
243
245
|
agentId,
|
|
246
|
+
runtime,
|
|
244
247
|
reason,
|
|
245
248
|
ok: pushed,
|
|
246
249
|
});
|
|
@@ -248,6 +251,7 @@ export async function startCloudDaemon(
|
|
|
248
251
|
|
|
249
252
|
const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
|
|
250
253
|
installedAgentIds.add(info.agentId);
|
|
254
|
+
if (info.runtime) runtimeByAgentId.set(info.agentId, info.runtime);
|
|
251
255
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
252
256
|
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
253
257
|
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
package/src/daemon.ts
CHANGED
|
@@ -249,8 +249,9 @@ export function pushRuntimeSnapshot(
|
|
|
249
249
|
export function pushAgentSkillSnapshot(
|
|
250
250
|
sink: RuntimeSnapshotSink,
|
|
251
251
|
agentId: string,
|
|
252
|
+
opts: { runtime?: string } = {},
|
|
252
253
|
): boolean {
|
|
253
|
-
const snap = collectAgentSkillSnapshot(agentId);
|
|
254
|
+
const snap = collectAgentSkillSnapshot(agentId, opts);
|
|
254
255
|
const ok = sink.send({
|
|
255
256
|
id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
256
257
|
type: "agent_skill_snapshot",
|
|
@@ -529,6 +530,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
529
530
|
// next room-context fetch re-loads the BotCordClient against the new
|
|
530
531
|
// credential file.
|
|
531
532
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
533
|
+
if (info.runtime) {
|
|
534
|
+
agentRuntimes[info.agentId] = {
|
|
535
|
+
...(agentRuntimes[info.agentId] ?? {}),
|
|
536
|
+
runtime: info.runtime,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
532
539
|
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
533
540
|
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
|
534
541
|
if (!scBuilders.has(info.agentId)) {
|
|
@@ -670,9 +677,11 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
670
677
|
ok: pushed,
|
|
671
678
|
});
|
|
672
679
|
for (const agentId of agentIds) {
|
|
673
|
-
const
|
|
680
|
+
const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
|
|
681
|
+
const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
|
|
674
682
|
logger.info("control-channel: initial agent_skill_snapshot push", {
|
|
675
683
|
agentId,
|
|
684
|
+
runtime,
|
|
676
685
|
ok: skillsPushed,
|
|
677
686
|
});
|
|
678
687
|
}
|
|
@@ -208,6 +208,31 @@ function buildRuntimeRecoveryPrompt(args: {
|
|
|
208
208
|
].join("\n");
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Pick the canonical reply_to value to attach to outbound replies for a given
|
|
213
|
+
* inbound `GatewayInboundMessage`. Priority:
|
|
214
|
+
*
|
|
215
|
+
* 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
|
|
216
|
+
* receipts and threaded replies point at the original target.
|
|
217
|
+
* 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
|
|
218
|
+
* This is the canonical form the hub stores in `reply_to_msg_id`.
|
|
219
|
+
* 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
|
|
220
|
+
* stamps on every inbound. The hub accepts this form via
|
|
221
|
+
* `_load_reply_target`'s prefix-based discriminator, but emitting it is
|
|
222
|
+
* lossy because the hub then has to resolve it back to msg_id.
|
|
223
|
+
*
|
|
224
|
+
* Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
|
|
225
|
+
*/
|
|
226
|
+
export function pickReplyToTarget(msg: GatewayInboundMessage): string {
|
|
227
|
+
if (msg.replyTo) return msg.replyTo;
|
|
228
|
+
const raw = msg.raw as { envelope?: { msg_id?: unknown } } | null | undefined;
|
|
229
|
+
const envMsgId =
|
|
230
|
+
raw && typeof raw.envelope?.msg_id === "string" && raw.envelope.msg_id
|
|
231
|
+
? raw.envelope.msg_id
|
|
232
|
+
: null;
|
|
233
|
+
return envMsgId ?? msg.id;
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
212
237
|
export type RuntimeFactory = (
|
|
213
238
|
runtimeId: string,
|
|
@@ -2132,7 +2157,7 @@ export class Dispatcher {
|
|
|
2132
2157
|
}
|
|
2133
2158
|
|
|
2134
2159
|
private providerReplyTo(msg: GatewayInboundMessage): string {
|
|
2135
|
-
return msg
|
|
2160
|
+
return pickReplyToTarget(msg);
|
|
2136
2161
|
}
|
|
2137
2162
|
|
|
2138
2163
|
private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
|
package/src/provision.ts
CHANGED
|
@@ -89,6 +89,12 @@ interface ListAgentSkillsParams {
|
|
|
89
89
|
agentId: string;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function runtimeForLoadedAgent(gateway: Gateway, agentId: string): string | undefined {
|
|
93
|
+
return gateway.listManagedRoutes()
|
|
94
|
+
.find((route) => route.match?.accountId === agentId)
|
|
95
|
+
?.runtime;
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
/**
|
|
93
99
|
* Information passed to {@link OnAgentInstalledHook} after a successful
|
|
94
100
|
* provision. Mirrors the credential fields the daemon's per-agent caches
|
|
@@ -510,9 +516,11 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
510
516
|
},
|
|
511
517
|
};
|
|
512
518
|
}
|
|
513
|
-
const
|
|
519
|
+
const runtime = runtimeForLoadedAgent(gateway, params.agentId);
|
|
520
|
+
const result = collectAgentSkillSnapshot(params.agentId, { runtime });
|
|
514
521
|
daemonLog.debug("list_agent_skills", {
|
|
515
522
|
agentId: params.agentId,
|
|
523
|
+
runtime,
|
|
516
524
|
count: result.skills.length,
|
|
517
525
|
});
|
|
518
526
|
return { ok: true, result };
|
package/src/skill-index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface AgentSkillSnapshot {
|
|
|
39
39
|
export interface SkillIndexOptions {
|
|
40
40
|
extraDirs?: string[];
|
|
41
41
|
includeGlobal?: boolean;
|
|
42
|
+
runtime?: string;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function defaultSkillDirs(
|
|
@@ -46,21 +47,26 @@ export function defaultSkillDirs(
|
|
|
46
47
|
opts: SkillIndexOptions = {},
|
|
47
48
|
): Array<{ dir: string; source: string }> {
|
|
48
49
|
const includeGlobal = opts.includeGlobal !== false;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const agentClaude = {
|
|
51
|
+
dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
|
|
52
|
+
source: "agent-claude",
|
|
53
|
+
};
|
|
54
|
+
const agentCodex = {
|
|
55
|
+
dir: path.join(agentCodexHomeDir(agentId), "skills"),
|
|
56
|
+
source: "agent-codex",
|
|
57
|
+
};
|
|
58
|
+
const dirs: Array<{ dir: string; source: string }> =
|
|
59
|
+
runtimeFamily(opts.runtime) === "codex"
|
|
60
|
+
? [agentCodex, agentClaude]
|
|
61
|
+
: [agentClaude, agentCodex];
|
|
59
62
|
|
|
60
63
|
if (includeGlobal) {
|
|
64
|
+
const globalClaude = { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" };
|
|
65
|
+
const globalCodex = { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" };
|
|
61
66
|
dirs.push(
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
...(runtimeFamily(opts.runtime) === "codex"
|
|
68
|
+
? [globalCodex, globalClaude]
|
|
69
|
+
: [globalClaude, globalCodex]),
|
|
64
70
|
);
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -69,7 +75,7 @@ export function defaultSkillDirs(
|
|
|
69
75
|
dirs.push({ dir, source: "external" });
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
return dedupeDirs(dirs);
|
|
78
|
+
return dedupeDirs(expandSkillRoots(dirs));
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
export function scanSoftSkills(
|
|
@@ -111,7 +117,7 @@ export function scanSoftSkills(
|
|
|
111
117
|
description: parsed.description,
|
|
112
118
|
mtimeMs: st.mtimeMs,
|
|
113
119
|
};
|
|
114
|
-
if (!existing || priority(root.source) < priority(existing.source)) {
|
|
120
|
+
if (!existing || priority(root.source, opts.runtime) < priority(existing.source, opts.runtime)) {
|
|
115
121
|
byName.set(entry.name, entry);
|
|
116
122
|
}
|
|
117
123
|
}
|
|
@@ -224,7 +230,41 @@ function dedupeDirs(
|
|
|
224
230
|
return out;
|
|
225
231
|
}
|
|
226
232
|
|
|
227
|
-
function
|
|
233
|
+
function expandSkillRoots(
|
|
234
|
+
dirs: Array<{ dir: string; source: string }>,
|
|
235
|
+
): Array<{ dir: string; source: string }> {
|
|
236
|
+
const out: Array<{ dir: string; source: string }> = [];
|
|
237
|
+
for (const entry of dirs) {
|
|
238
|
+
out.push(entry);
|
|
239
|
+
if (entry.source.includes("codex")) {
|
|
240
|
+
out.push({ dir: path.join(entry.dir, ".system"), source: entry.source });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function runtimeFamily(runtime: string | undefined): "codex" | "claude" | "other" {
|
|
247
|
+
if (runtime === "codex") return "codex";
|
|
248
|
+
if (runtime === "claude-code") return "claude";
|
|
249
|
+
return "other";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function priority(source: string, runtime: string | undefined): number {
|
|
253
|
+
if (runtimeFamily(runtime) === "codex") {
|
|
254
|
+
switch (source) {
|
|
255
|
+
case "agent-codex":
|
|
256
|
+
return 0;
|
|
257
|
+
case "global-codex":
|
|
258
|
+
return 1;
|
|
259
|
+
case "agent-claude":
|
|
260
|
+
return 2;
|
|
261
|
+
case "global-claude":
|
|
262
|
+
return 3;
|
|
263
|
+
default:
|
|
264
|
+
return 4;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
228
268
|
switch (source) {
|
|
229
269
|
case "agent-claude":
|
|
230
270
|
return 0;
|
package/src/turn-text.ts
CHANGED
|
@@ -112,6 +112,18 @@ interface BatchedEntry {
|
|
|
112
112
|
source_type?: unknown;
|
|
113
113
|
source_user_name?: unknown;
|
|
114
114
|
mentioned?: unknown;
|
|
115
|
+
reply_preview?: ReplyPreviewRaw | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Structural mirror of protocol-core ReplyPreview — kept local so the
|
|
119
|
+
* composer has no cross-package import. */
|
|
120
|
+
interface ReplyPreviewRaw {
|
|
121
|
+
msg_id?: unknown;
|
|
122
|
+
sender_id?: unknown;
|
|
123
|
+
sender_display_name?: unknown;
|
|
124
|
+
text_preview?: unknown;
|
|
125
|
+
topic_id?: unknown;
|
|
126
|
+
deleted?: unknown;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
interface RoomContextRaw {
|
|
@@ -196,6 +208,40 @@ function entryText(e: BatchedEntry): string {
|
|
|
196
208
|
return "";
|
|
197
209
|
}
|
|
198
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Format the inline quote-reply context line that prefixes a message body
|
|
213
|
+
* when the inbound envelope replies to another message. Single layer — we
|
|
214
|
+
* never render a quote-of-a-quote chain. Returns `null` when the source
|
|
215
|
+
* carries no reply_preview, so the caller can skip emitting an empty line.
|
|
216
|
+
*
|
|
217
|
+
* Both sender label and preview body are sanitized; the preview is hard-
|
|
218
|
+
* capped at 120 chars to mirror the backend truncation.
|
|
219
|
+
*/
|
|
220
|
+
function formatReplyQuoteLine(raw: unknown): string | null {
|
|
221
|
+
if (!raw || typeof raw !== "object") return null;
|
|
222
|
+
const rp = (raw as { reply_preview?: ReplyPreviewRaw | null }).reply_preview;
|
|
223
|
+
if (!rp || typeof rp !== "object") return null;
|
|
224
|
+
if (rp.deleted === true) {
|
|
225
|
+
return "[quoting (deleted message)]";
|
|
226
|
+
}
|
|
227
|
+
const senderRaw =
|
|
228
|
+
typeof rp.sender_display_name === "string" && rp.sender_display_name
|
|
229
|
+
? rp.sender_display_name
|
|
230
|
+
: typeof rp.sender_id === "string" && rp.sender_id
|
|
231
|
+
? rp.sender_id
|
|
232
|
+
: "unknown";
|
|
233
|
+
const sender = sanitizeSenderName(senderRaw);
|
|
234
|
+
const previewRaw =
|
|
235
|
+
typeof rp.text_preview === "string" ? rp.text_preview : "";
|
|
236
|
+
const previewClean = sanitizeUntrustedContent(previewRaw)
|
|
237
|
+
.replace(/[\r\n]+/g, " ")
|
|
238
|
+
.slice(0, 120);
|
|
239
|
+
if (!previewClean) {
|
|
240
|
+
return `[quoting ${sender}]`;
|
|
241
|
+
}
|
|
242
|
+
return `[quoting ${sender}: "${previewClean}"]`;
|
|
243
|
+
}
|
|
244
|
+
|
|
199
245
|
function formatRoomContext(raw: unknown, fallback: { id: string; title?: string }): string[] {
|
|
200
246
|
const r = raw && typeof raw === "object" ? (raw as RoomContextRaw) : {};
|
|
201
247
|
const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
|
|
@@ -293,11 +339,13 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
|
|
|
293
339
|
"agent ID and any message they attached.]"
|
|
294
340
|
: null;
|
|
295
341
|
|
|
342
|
+
const quoteLine = formatReplyQuoteLine(msg.raw);
|
|
296
343
|
const lines: string[] = [
|
|
297
344
|
headerFields.join(" | "),
|
|
298
345
|
...formatScheduleContext(msg.raw),
|
|
299
346
|
...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
|
|
300
347
|
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
348
|
+
...(quoteLine ? [quoteLine] : []),
|
|
301
349
|
trimmed,
|
|
302
350
|
`</${tag}>`,
|
|
303
351
|
"",
|
|
@@ -350,8 +398,10 @@ function composeBatchedTurn(
|
|
|
350
398
|
// non-owner. Still sanitize defensively.
|
|
351
399
|
const safeBody = sanitizeUntrustedContent(raw);
|
|
352
400
|
const tag = kind === "human" ? "human-message" : "agent-message";
|
|
401
|
+
const quoteLine = formatReplyQuoteLine(entry);
|
|
402
|
+
const inner = quoteLine ? `${quoteLine}\n${safeBody}` : safeBody;
|
|
353
403
|
blocks.push(
|
|
354
|
-
`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${
|
|
404
|
+
`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${inner}\n</${tag}>`,
|
|
355
405
|
);
|
|
356
406
|
if (envelopeType === "contact_request") {
|
|
357
407
|
contactRequestSenders.push(safeLabel);
|