@botcord/daemon 0.2.78 → 0.2.80
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/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon-singleton.d.ts +13 -0
- package/dist/daemon-singleton.js +68 -0
- package/dist/daemon.js +21 -6
- package/dist/gateway/channels/botcord.d.ts +1 -0
- package/dist/gateway/channels/botcord.js +62 -17
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +56 -13
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/index.js +8 -3
- package/dist/provision.d.ts +7 -3
- package/dist/provision.js +115 -8
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/status-render.d.ts +4 -0
- package/dist/status-render.js +14 -1
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/daemon-singleton.test.ts +32 -0
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +65 -0
- package/src/__tests__/status-render.test.ts +23 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon-singleton.ts +85 -0
- package/src/daemon.ts +23 -6
- package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +69 -17
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +63 -13
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/index.ts +9 -2
- package/src/provision.ts +133 -7
- package/src/room-recovery-context.ts +131 -0
- package/src/status-render.ts +14 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RUNTIME_FRAME_TYPES,
|
|
3
|
+
type GatewayInboundFrame,
|
|
4
|
+
} from "@botcord/protocol-core";
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ChannelAdapter,
|
|
8
|
+
ChannelSendContext,
|
|
9
|
+
ChannelSendResult,
|
|
10
|
+
ChannelStatusSnapshot,
|
|
11
|
+
Gateway,
|
|
12
|
+
GatewayInboundMessage,
|
|
13
|
+
GatewayLogger,
|
|
14
|
+
} from "./gateway/index.js";
|
|
15
|
+
|
|
16
|
+
export interface CloudGatewayRuntimeResult {
|
|
17
|
+
accepted: boolean;
|
|
18
|
+
eventId: string;
|
|
19
|
+
gatewayId: string;
|
|
20
|
+
agentId: string;
|
|
21
|
+
conversationId: string;
|
|
22
|
+
turnId: string;
|
|
23
|
+
outbound?: {
|
|
24
|
+
finalText: string;
|
|
25
|
+
providerMessageId?: string | null;
|
|
26
|
+
};
|
|
27
|
+
error?: {
|
|
28
|
+
code: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Execute one ingress-originated runtime frame through the normal Gateway
|
|
35
|
+
* dispatcher while keeping provider I/O outside the cloud sandbox.
|
|
36
|
+
*
|
|
37
|
+
* The temporary channel is scoped to this call and implements only the
|
|
38
|
+
* dispatcher-facing send/status surface. Its send() method captures the
|
|
39
|
+
* final runtime reply; the Hub relay converts that into
|
|
40
|
+
* gateway_outbound_complete for gateway-ingress, which then calls the real
|
|
41
|
+
* provider API.
|
|
42
|
+
*/
|
|
43
|
+
export async function handleCloudGatewayRuntimeInbound(
|
|
44
|
+
gateway: Gateway,
|
|
45
|
+
frame: GatewayInboundFrame,
|
|
46
|
+
log?: GatewayLogger,
|
|
47
|
+
): Promise<CloudGatewayRuntimeResult> {
|
|
48
|
+
if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
|
|
49
|
+
return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
|
|
50
|
+
}
|
|
51
|
+
if (!frame.gateway_id || !frame.agent_id || !frame.event_id) {
|
|
52
|
+
return rejected(frame, "bad_frame", "gateway_id, agent_id and event_id are required");
|
|
53
|
+
}
|
|
54
|
+
if (frame.message.accountId !== frame.agent_id) {
|
|
55
|
+
return rejected(frame, "account_mismatch", "message.accountId does not match frame.agent_id");
|
|
56
|
+
}
|
|
57
|
+
if (frame.message.channel !== frame.gateway_id) {
|
|
58
|
+
return rejected(frame, "channel_mismatch", "message.channel does not match frame.gateway_id");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let accepted = false;
|
|
62
|
+
let outboundText: string | null = null;
|
|
63
|
+
let providerMessageId: string | null | undefined;
|
|
64
|
+
const channel = createRuntimeRelayChannel({
|
|
65
|
+
id: frame.gateway_id,
|
|
66
|
+
provider: frame.provider,
|
|
67
|
+
accountId: frame.agent_id,
|
|
68
|
+
onSend: async (ctx) => {
|
|
69
|
+
outboundText = ctx.message.text ?? "";
|
|
70
|
+
providerMessageId = ctx.message.traceId ?? null;
|
|
71
|
+
return { providerMessageId };
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const message: GatewayInboundMessage = {
|
|
76
|
+
...frame.message,
|
|
77
|
+
raw: {
|
|
78
|
+
source_type: "cloud_gateway_ingress",
|
|
79
|
+
provider: frame.provider,
|
|
80
|
+
event_id: frame.event_id,
|
|
81
|
+
gateway_id: frame.gateway_id,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await gateway.injectInboundThrough(message, channel, {
|
|
87
|
+
accept: async () => {
|
|
88
|
+
accepted = true;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
log?.warn("cloud gateway runtime dispatch failed", {
|
|
94
|
+
eventId: frame.event_id,
|
|
95
|
+
gatewayId: frame.gateway_id,
|
|
96
|
+
agentId: frame.agent_id,
|
|
97
|
+
error: message,
|
|
98
|
+
});
|
|
99
|
+
return rejected(frame, "dispatch_failed", message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
accepted,
|
|
104
|
+
eventId: frame.event_id,
|
|
105
|
+
gatewayId: frame.gateway_id,
|
|
106
|
+
agentId: frame.agent_id,
|
|
107
|
+
conversationId: frame.message.conversation.id,
|
|
108
|
+
turnId: `turn_${frame.event_id}`,
|
|
109
|
+
...(outboundText !== null
|
|
110
|
+
? {
|
|
111
|
+
outbound: {
|
|
112
|
+
finalText: outboundText,
|
|
113
|
+
providerMessageId: providerMessageId ?? null,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
...(!accepted
|
|
118
|
+
? { error: { code: "not_accepted", message: "dispatcher did not accept inbound" } }
|
|
119
|
+
: {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createRuntimeRelayChannel(opts: {
|
|
124
|
+
id: string;
|
|
125
|
+
provider: string;
|
|
126
|
+
accountId: string;
|
|
127
|
+
onSend: (ctx: ChannelSendContext) => Promise<ChannelSendResult>;
|
|
128
|
+
}): ChannelAdapter {
|
|
129
|
+
let lastSendAt: number | undefined;
|
|
130
|
+
return {
|
|
131
|
+
id: opts.id,
|
|
132
|
+
type: opts.provider,
|
|
133
|
+
async start() {
|
|
134
|
+
return undefined;
|
|
135
|
+
},
|
|
136
|
+
async stop() {
|
|
137
|
+
return undefined;
|
|
138
|
+
},
|
|
139
|
+
async send(ctx) {
|
|
140
|
+
lastSendAt = Date.now();
|
|
141
|
+
return opts.onSend(ctx);
|
|
142
|
+
},
|
|
143
|
+
status(): ChannelStatusSnapshot {
|
|
144
|
+
return {
|
|
145
|
+
channel: opts.id,
|
|
146
|
+
accountId: opts.accountId,
|
|
147
|
+
running: true,
|
|
148
|
+
connected: true,
|
|
149
|
+
authorized: true,
|
|
150
|
+
provider: opts.provider as ChannelStatusSnapshot["provider"],
|
|
151
|
+
...(lastSendAt ? { lastSendAt } : {}),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rejected(
|
|
158
|
+
frame: Partial<GatewayInboundFrame>,
|
|
159
|
+
code: string,
|
|
160
|
+
message: string,
|
|
161
|
+
): CloudGatewayRuntimeResult {
|
|
162
|
+
return {
|
|
163
|
+
accepted: false,
|
|
164
|
+
eventId: frame.event_id ?? "",
|
|
165
|
+
gatewayId: frame.gateway_id ?? "",
|
|
166
|
+
agentId: frame.agent_id ?? "",
|
|
167
|
+
conversationId: frame.message?.conversation.id ?? "",
|
|
168
|
+
turnId: frame.event_id ? `turn_${frame.event_id}` : "turn_unknown",
|
|
169
|
+
error: { code, message },
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/daemon-singleton.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { PID_PATH } from "./config.js";
|
|
3
4
|
|
|
@@ -31,6 +32,46 @@ export function pidAlive(pid: number): boolean {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export interface DaemonProcessInfo {
|
|
36
|
+
pid: number;
|
|
37
|
+
command: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseDaemonProcesses(
|
|
41
|
+
psOutput: string,
|
|
42
|
+
currentPid: number = process.pid,
|
|
43
|
+
): DaemonProcessInfo[] {
|
|
44
|
+
const out: DaemonProcessInfo[] = [];
|
|
45
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
const match = /^(\d+)\s+(.+)$/.exec(trimmed);
|
|
48
|
+
if (!match) continue;
|
|
49
|
+
const pid = Number(match[1]);
|
|
50
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid) continue;
|
|
51
|
+
const command = match[2] ?? "";
|
|
52
|
+
if (!isBotCordDaemonStartCommand(command)) continue;
|
|
53
|
+
out.push({ pid, command });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findOtherDaemonProcesses(
|
|
59
|
+
opts: {
|
|
60
|
+
currentPid?: number;
|
|
61
|
+
} = {},
|
|
62
|
+
): DaemonProcessInfo[] {
|
|
63
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
64
|
+
try {
|
|
65
|
+
const output = execFileSync("ps", ["-axo", "pid=,command="], {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
68
|
+
});
|
|
69
|
+
return parseDaemonProcesses(output, currentPid).filter((p) => pidAlive(p.pid));
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
35
76
|
const deadline = Date.now() + timeoutMs;
|
|
36
77
|
while (Date.now() < deadline) {
|
|
@@ -85,6 +126,41 @@ export async function stopDaemonFromPidFileForRestart(
|
|
|
85
126
|
}
|
|
86
127
|
}
|
|
87
128
|
|
|
129
|
+
export async function stopOtherDaemonProcessesForRestart(
|
|
130
|
+
opts: {
|
|
131
|
+
currentPid?: number;
|
|
132
|
+
logger?: SingletonLogger;
|
|
133
|
+
processes?: DaemonProcessInfo[];
|
|
134
|
+
} = {},
|
|
135
|
+
): Promise<DaemonProcessInfo[]> {
|
|
136
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
137
|
+
const logger = opts.logger ?? noopLogger;
|
|
138
|
+
const processes = opts.processes ?? findOtherDaemonProcesses({ currentPid });
|
|
139
|
+
for (const proc of processes) {
|
|
140
|
+
logger.info("additional daemon process found; restarting", {
|
|
141
|
+
pid: proc.pid,
|
|
142
|
+
command: proc.command,
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
process.kill(proc.pid, "SIGTERM");
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!(await waitForPidExit(proc.pid, 5_000))) {
|
|
150
|
+
logger.warn("additional daemon did not stop after SIGTERM; sending SIGKILL", {
|
|
151
|
+
pid: proc.pid,
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
process.kill(proc.pid, "SIGKILL");
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
await waitForPidExit(proc.pid, 2_000);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return processes;
|
|
162
|
+
}
|
|
163
|
+
|
|
88
164
|
export function ensureNoOtherDaemonFromPidFile(
|
|
89
165
|
opts: {
|
|
90
166
|
pidPath?: string;
|
|
@@ -117,6 +193,15 @@ export function removePidFile(pidPath = PID_PATH): void {
|
|
|
117
193
|
}
|
|
118
194
|
}
|
|
119
195
|
|
|
196
|
+
function isBotCordDaemonStartCommand(command: string): boolean {
|
|
197
|
+
if (!/\bstart\b/.test(command)) return false;
|
|
198
|
+
return (
|
|
199
|
+
command.includes("botcord-daemon") ||
|
|
200
|
+
/(?:^|\s)\S*botcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
|
|
201
|
+
/(?:^|\s)\S*packages\/daemon\/dist\/index\.js(?:\s|$)/.test(command)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
120
205
|
function delay(ms: number): Promise<void> {
|
|
121
206
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
207
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
|
39
39
|
import { readWorkingMemorySnapshot } from "./working-memory.js";
|
|
40
40
|
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
41
41
|
import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
42
|
+
import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
|
|
42
43
|
import {
|
|
43
44
|
buildLoopRiskPrompt,
|
|
44
45
|
loopRiskSessionKey,
|
|
@@ -50,6 +51,7 @@ import { UserAuthManager } from "./user-auth.js";
|
|
|
50
51
|
import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
|
|
51
52
|
import { scanMention } from "./mention-scan.js";
|
|
52
53
|
import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
|
|
54
|
+
import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Default hard cap for a single runtime turn. Long-running coding/research
|
|
@@ -364,6 +366,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
364
366
|
fetchRoomInfo: roomContextFetcher,
|
|
365
367
|
log: logger,
|
|
366
368
|
});
|
|
369
|
+
const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
|
|
370
|
+
credentialPathByAgentId,
|
|
371
|
+
...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
|
|
372
|
+
...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
|
|
373
|
+
limit: 20,
|
|
374
|
+
log: logger,
|
|
375
|
+
});
|
|
367
376
|
|
|
368
377
|
// Cache one system-context builder per configured agentId. The gateway
|
|
369
378
|
// calls this with each inbound message and we pick the right builder by
|
|
@@ -442,13 +451,20 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
442
451
|
});
|
|
443
452
|
};
|
|
444
453
|
|
|
445
|
-
// Per-agent attention policy cache (
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
454
|
+
// Per-agent attention policy cache (design §4.2 / §5). It is seeded from
|
|
455
|
+
// `provision_agent` / `policy_updated` frames when available and falls back
|
|
456
|
+
// to Hub on cold misses, so daemon restarts preserve dashboard policy.
|
|
457
|
+
const fetchAttentionPolicy = createAttentionPolicyFetcher({
|
|
458
|
+
credentialPathByAgentId,
|
|
459
|
+
defaultCredentialsPath: opts.credentialsPath,
|
|
460
|
+
hubBaseUrl: opts.hubBaseUrl,
|
|
461
|
+
log: logger,
|
|
462
|
+
});
|
|
450
463
|
const policyResolver = new PolicyResolver({
|
|
451
|
-
fetchGlobal: async (
|
|
464
|
+
fetchGlobal: async (agentId: string) =>
|
|
465
|
+
fetchAttentionPolicy({ agentId, roomId: null }),
|
|
466
|
+
fetchEffective: async (agentId: string, roomId: string) =>
|
|
467
|
+
fetchAttentionPolicy({ agentId, roomId }),
|
|
452
468
|
});
|
|
453
469
|
|
|
454
470
|
// Display-name lookup for the mention text-fallback. Populated from boot
|
|
@@ -528,6 +544,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
528
544
|
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
529
545
|
buildSystemContext,
|
|
530
546
|
buildMemoryContext,
|
|
547
|
+
buildRuntimeRecoveryContext,
|
|
531
548
|
onInbound,
|
|
532
549
|
onOutbound,
|
|
533
550
|
onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
|
|
@@ -699,7 +699,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
699
699
|
},
|
|
700
700
|
1,
|
|
701
701
|
),
|
|
702
|
-
).
|
|
702
|
+
).toMatchObject({
|
|
703
703
|
kind: "tool_call",
|
|
704
704
|
seq: 1,
|
|
705
705
|
payload: {
|
|
@@ -725,7 +725,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
725
725
|
},
|
|
726
726
|
2,
|
|
727
727
|
),
|
|
728
|
-
).
|
|
728
|
+
).toMatchObject({
|
|
729
729
|
kind: "tool_call",
|
|
730
730
|
seq: 2,
|
|
731
731
|
payload: {
|
|
@@ -758,7 +758,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
758
758
|
},
|
|
759
759
|
3,
|
|
760
760
|
),
|
|
761
|
-
).
|
|
761
|
+
).toMatchObject({
|
|
762
762
|
kind: "tool_result",
|
|
763
763
|
seq: 3,
|
|
764
764
|
payload: {
|
|
@@ -782,7 +782,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
782
782
|
},
|
|
783
783
|
4,
|
|
784
784
|
),
|
|
785
|
-
).
|
|
785
|
+
).toMatchObject({
|
|
786
786
|
kind: "tool_call",
|
|
787
787
|
seq: 4,
|
|
788
788
|
payload: {
|
|
@@ -925,6 +925,211 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
925
925
|
}
|
|
926
926
|
});
|
|
927
927
|
|
|
928
|
+
it("normalizes current DeepSeek item.delta assistant text", async () => {
|
|
929
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
930
|
+
const realFetch = globalThis.fetch;
|
|
931
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
932
|
+
try {
|
|
933
|
+
const client = makeClient({
|
|
934
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
935
|
+
});
|
|
936
|
+
const channel = createBotCordChannel({
|
|
937
|
+
id: "botcord-main",
|
|
938
|
+
accountId: "ag_self",
|
|
939
|
+
agentId: "ag_self",
|
|
940
|
+
client,
|
|
941
|
+
hubBaseUrl: "https://hub.example.com",
|
|
942
|
+
});
|
|
943
|
+
await channel.streamBlock!({
|
|
944
|
+
traceId: "m_trace",
|
|
945
|
+
accountId: "ag_self",
|
|
946
|
+
conversationId: "rm_oc_1",
|
|
947
|
+
block: {
|
|
948
|
+
kind: "assistant_text",
|
|
949
|
+
seq: 6,
|
|
950
|
+
raw: {
|
|
951
|
+
event: "item.delta",
|
|
952
|
+
payload: { thread_id: "thr_1", turn_id: "turn_1", kind: "agent_message", delta: "hello" },
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
log: silentLog,
|
|
956
|
+
});
|
|
957
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
958
|
+
const body = JSON.parse(init.body as string);
|
|
959
|
+
expect(body.block).toEqual({
|
|
960
|
+
kind: "assistant",
|
|
961
|
+
seq: 6,
|
|
962
|
+
payload: { text: "hello" },
|
|
963
|
+
});
|
|
964
|
+
} finally {
|
|
965
|
+
globalThis.fetch = realFetch;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("normalizes current DeepSeek item.started tool input", () => {
|
|
970
|
+
expect(
|
|
971
|
+
__normalizeBlockForHubForTests(
|
|
972
|
+
{
|
|
973
|
+
kind: "tool_use",
|
|
974
|
+
seq: 7,
|
|
975
|
+
raw: {
|
|
976
|
+
event: "item.started",
|
|
977
|
+
payload: {
|
|
978
|
+
item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
|
|
979
|
+
tool: { id: "call_1", name: "web_search", input: { query: "上海天气" } },
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
7,
|
|
984
|
+
),
|
|
985
|
+
).toMatchObject({
|
|
986
|
+
kind: "tool_call",
|
|
987
|
+
seq: 7,
|
|
988
|
+
payload: {
|
|
989
|
+
id: "call_1",
|
|
990
|
+
name: "web_search",
|
|
991
|
+
params: { query: "上海天气" },
|
|
992
|
+
status: "in_progress",
|
|
993
|
+
},
|
|
994
|
+
raw: {
|
|
995
|
+
event: "item.started",
|
|
996
|
+
payload: {
|
|
997
|
+
item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
|
|
998
|
+
tool: { id: "call_1", name: "web_search", input: { query: "上海天气" } },
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("infers current DeepSeek tool input from item summary and detail", () => {
|
|
1005
|
+
expect(
|
|
1006
|
+
__normalizeBlockForHubForTests(
|
|
1007
|
+
{
|
|
1008
|
+
kind: "tool_use",
|
|
1009
|
+
seq: 8,
|
|
1010
|
+
raw: {
|
|
1011
|
+
event: "item.started",
|
|
1012
|
+
payload: {
|
|
1013
|
+
item: {
|
|
1014
|
+
id: "item_exec",
|
|
1015
|
+
kind: "tool_call",
|
|
1016
|
+
status: "in_progress",
|
|
1017
|
+
summary: "exec_shell started",
|
|
1018
|
+
detail: "{\"cmd\":\"botcord-daemon --version\"}",
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
8,
|
|
1024
|
+
),
|
|
1025
|
+
).toMatchObject({
|
|
1026
|
+
kind: "tool_call",
|
|
1027
|
+
seq: 8,
|
|
1028
|
+
payload: {
|
|
1029
|
+
id: "item_exec",
|
|
1030
|
+
name: "exec_shell",
|
|
1031
|
+
params: { cmd: "botcord-daemon --version" },
|
|
1032
|
+
status: "in_progress",
|
|
1033
|
+
},
|
|
1034
|
+
raw: {
|
|
1035
|
+
event: "item.started",
|
|
1036
|
+
payload: {
|
|
1037
|
+
item: {
|
|
1038
|
+
id: "item_exec",
|
|
1039
|
+
kind: "tool_call",
|
|
1040
|
+
status: "in_progress",
|
|
1041
|
+
summary: "exec_shell started",
|
|
1042
|
+
detail: "{\"cmd\":\"botcord-daemon --version\"}",
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("infers current DeepSeek tool result name from item summary", () => {
|
|
1050
|
+
expect(
|
|
1051
|
+
__normalizeBlockForHubForTests(
|
|
1052
|
+
{
|
|
1053
|
+
kind: "tool_result",
|
|
1054
|
+
seq: 9,
|
|
1055
|
+
raw: {
|
|
1056
|
+
event: "item.completed",
|
|
1057
|
+
payload: {
|
|
1058
|
+
item: {
|
|
1059
|
+
id: "item_exec",
|
|
1060
|
+
kind: "tool_call",
|
|
1061
|
+
status: "completed",
|
|
1062
|
+
summary: "exec_shell: botcord-daemon 0.2.78",
|
|
1063
|
+
detail: "botcord-daemon 0.2.78",
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
9,
|
|
1069
|
+
),
|
|
1070
|
+
).toMatchObject({
|
|
1071
|
+
kind: "tool_result",
|
|
1072
|
+
seq: 9,
|
|
1073
|
+
payload: {
|
|
1074
|
+
name: "exec_shell",
|
|
1075
|
+
result: "botcord-daemon 0.2.78",
|
|
1076
|
+
tool_use_id: "item_exec",
|
|
1077
|
+
},
|
|
1078
|
+
raw: {
|
|
1079
|
+
event: "item.completed",
|
|
1080
|
+
payload: {
|
|
1081
|
+
item: {
|
|
1082
|
+
id: "item_exec",
|
|
1083
|
+
kind: "tool_call",
|
|
1084
|
+
status: "completed",
|
|
1085
|
+
summary: "exec_shell: botcord-daemon 0.2.78",
|
|
1086
|
+
detail: "botcord-daemon 0.2.78",
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it("normalizes current DeepSeek agent_reasoning details", () => {
|
|
1094
|
+
expect(
|
|
1095
|
+
__normalizeBlockForHubForTests(
|
|
1096
|
+
{
|
|
1097
|
+
kind: "thinking",
|
|
1098
|
+
seq: 8,
|
|
1099
|
+
raw: {
|
|
1100
|
+
event: "item.completed",
|
|
1101
|
+
payload: {
|
|
1102
|
+
item: {
|
|
1103
|
+
id: "item_reasoning",
|
|
1104
|
+
kind: "agent_reasoning",
|
|
1105
|
+
status: "completed",
|
|
1106
|
+
summary: "I should answer briefly.",
|
|
1107
|
+
detail: "I should answer briefly.",
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
8,
|
|
1113
|
+
),
|
|
1114
|
+
).toMatchObject({
|
|
1115
|
+
kind: "thinking",
|
|
1116
|
+
seq: 8,
|
|
1117
|
+
payload: { details: "I should answer briefly." },
|
|
1118
|
+
raw: {
|
|
1119
|
+
event: "item.completed",
|
|
1120
|
+
payload: {
|
|
1121
|
+
item: {
|
|
1122
|
+
id: "item_reasoning",
|
|
1123
|
+
kind: "agent_reasoning",
|
|
1124
|
+
status: "completed",
|
|
1125
|
+
summary: "I should answer briefly.",
|
|
1126
|
+
detail: "I should answer briefly.",
|
|
1127
|
+
},
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
928
1133
|
it("marks DeepSeek terminal events for owner-chat stream cleanup", async () => {
|
|
929
1134
|
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
930
1135
|
const realFetch = globalThis.fetch;
|
|
@@ -993,10 +1198,11 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
993
1198
|
log: silentLog,
|
|
994
1199
|
});
|
|
995
1200
|
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string);
|
|
996
|
-
expect(body.block).
|
|
1201
|
+
expect(body.block).toMatchObject({
|
|
997
1202
|
kind: "thinking",
|
|
998
1203
|
seq: 7,
|
|
999
1204
|
payload: { phase: "updated", label: "Searching web", source: "runtime", details: "Searching web" },
|
|
1205
|
+
raw: { phase: "updated", label: "Searching web", source: "runtime" },
|
|
1000
1206
|
});
|
|
1001
1207
|
} finally {
|
|
1002
1208
|
globalThis.fetch = realFetch;
|