@dcrays/dcgchat 0.4.13 → 0.4.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat",
3
- "version": "0.4.13",
3
+ "version": "0.4.24",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/bot.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import path from 'node:path'
2
3
  import type { ReplyPayload } from 'openclaw/plugin-sdk'
3
4
  import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
@@ -14,10 +15,20 @@ import { resolveAccount, sendDcgchatMedia } from './channel.js'
14
15
  import { generateSignUrl } from './request/api.js'
15
16
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
16
17
  import { dcgLogger } from './utils/log.js'
17
- import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
18
- import { sendGatewayRpc } from './gateway/socket.js'
18
+ import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
19
19
  import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
20
- import { getChildSessionKeysTrackedForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
20
+ import { waitUntilSubagentsIdle } from './tool.js'
21
+ import {
22
+ beginSupersedingUserTurn,
23
+ clearActiveRunIdForSession,
24
+ clearSessionStreamSuppression,
25
+ interruptLocalDispatchAndGateway,
26
+ isSessionStreamSuppressed,
27
+ preemptInboundQueueForStop,
28
+ releaseDispatchAbortIfCurrent,
29
+ runInboundTurnSequenced,
30
+ setActiveRunIdForSession
31
+ } from './sessionTermination.js'
21
32
 
22
33
  type MediaInfo = {
23
34
  path: string
@@ -30,14 +41,18 @@ type TFileInfo = { name: string; url: string }
30
41
 
31
42
  const mediaMaxBytes = 300 * 1024 * 1024
32
43
 
33
- /** 当前会话最近一次 agent run 的 runId(供 chat.abort 精确打断;无则仅传 sessionKey 仍会中止该会话全部活动运行) */
34
- const activeRunIdBySessionKey = new Map<string, string>()
35
-
36
44
  /**
37
- * 用户在该 sessionKey 上触发打断后,旧 run 的流式/投递不再下发;与 sessionKey 一一对应,支持多会话。
38
- * 清除时机:① 下一条非打断用户消息开始处理时;② run 收尾到 mobook 段时若仍抑制则跳过并发后删除。
45
+ * 每条入站消息(含 /stop)递增。用于丢弃「已被后续入站抢占」的处理在 dispatch 结束后的尾部逻辑。
46
+ * /stop 时的 epoch 无法覆盖「新用户消息抢占上一轮」的并发 WS 场景,会导致旧 handler 仍执行 sendFinal(end)、
47
+ * 而新消息被误判为 stale;且上一轮若未对网关 chat.abort,服务端 run 会继续,表现为「新消息秒结束、旧回复复活」。
39
48
  */
40
- const sessionStreamSuppressed = new Set<string>()
49
+ const inboundGenerationBySessionKey = new Map<string, number>()
50
+
51
+ function bumpInboundGeneration(sessionKey: string): number {
52
+ const n = (inboundGenerationBySessionKey.get(sessionKey) ?? 0) + 1
53
+ inboundGenerationBySessionKey.set(sessionKey, n)
54
+ return n
55
+ }
41
56
 
42
57
  /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
43
58
  const streamChunkIdxBySessionKey = new Map<string, number>()
@@ -140,16 +155,29 @@ const typingCallbacks = createTypingCallbacks({
140
155
  })
141
156
 
142
157
  /**
143
- * 处理一条用户消息,调用 Agent 并返回回复
158
+ * 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
144
159
  */
145
160
  export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
146
- let completeText = ''
147
161
  const config = getOpenClawConfig()
148
162
  if (!config) {
149
163
  dcgLogger('no config available', 'error')
150
164
  return
151
165
  }
152
166
  const account = resolveAccount(config, accountId)
167
+ const queueSessionKey = getSessionKey(msg.content, account.accountId)
168
+ const queueText = (msg.content.text ?? '').trim()
169
+ if (stopCommand.includes(queueText)) {
170
+ preemptInboundQueueForStop(queueSessionKey)
171
+ }
172
+ await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
173
+ }
174
+
175
+ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
176
+ const config = getOpenClawConfig()
177
+ if (!config) {
178
+ return
179
+ }
180
+ const account = resolveAccount(config, accountId)
153
181
  const userId = msg._userId.toString()
154
182
 
155
183
  const core = getDcgchatRuntime()
@@ -168,6 +196,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
168
196
 
169
197
  const effectiveAgentId = embeddedAgentId ?? route.agentId
170
198
  const dcgSessionKey = getSessionKey(msg.content, account.accountId)
199
+ const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
171
200
 
172
201
  const mergedParams = {
173
202
  userId: msg._userId,
@@ -175,7 +204,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
175
204
  sessionId: conversationId,
176
205
  messageId: msg.content.message_id,
177
206
  domainId: msg.content.domain_id,
178
- appId: msg.content.app_id,
207
+ appId: config.channels?.["dcgchat"]?.appId || 100,
179
208
  botId: msg.content.bot_id ?? '',
180
209
  agentId: msg.content.agent_id ?? '',
181
210
  sessionKey: dcgSessionKey,
@@ -195,6 +224,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
195
224
  if (!text) {
196
225
  sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
197
226
  sendFinal(outboundCtx, 'not text')
227
+ setMsgStatus(dcgSessionKey, 'finished')
198
228
  return
199
229
  }
200
230
 
@@ -251,7 +281,8 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
251
281
 
252
282
  const sentMediaKeys = new Set<string>()
253
283
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
254
- let streamedTextLen = 0
284
+ /** Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
285
+ let lastStreamSnapshot = ''
255
286
 
256
287
  if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
257
288
  const workspaceDir = getWorkspaceDir()
@@ -274,7 +305,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
274
305
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
275
306
  onReplyStart: async () => {},
276
307
  deliver: async (payload: ReplyPayload, info) => {
277
- if (sessionStreamSuppressed.has(dcgSessionKey)) return
308
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
278
309
  const mediaList = resolveReplyMediaList(payload)
279
310
  for (const mediaUrl of mediaList) {
280
311
  const key = getMediaKey(mediaUrl)
@@ -287,9 +318,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
287
318
  onError: (err: unknown, info: { kind: string }) => {
288
319
  setMsgStatus(dcgSessionKey, 'finished')
289
320
  sendFinal(outboundCtx, 'error')
290
- activeRunIdBySessionKey.delete(dcgSessionKey)
321
+ clearActiveRunIdForSession(dcgSessionKey)
291
322
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
292
- const suppressed = sessionStreamSuppressed.has(dcgSessionKey)
323
+ const suppressed = isSessionStreamSuppressed(dcgSessionKey)
293
324
  dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
294
325
  },
295
326
  onIdle: () => {
@@ -297,10 +328,11 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
297
328
  }
298
329
  })
299
330
 
331
+ let dispatchAbort: AbortController | undefined
300
332
  try {
301
- if (!interruptCommand.includes(text?.trim())) {
302
- sessionStreamSuppressed.delete(dcgSessionKey)
333
+ if (!stopCommand.includes(text?.trim())) {
303
334
  streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
335
+ dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
304
336
  }
305
337
 
306
338
  if (systemCommand.includes(text?.trim())) {
@@ -315,49 +347,22 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
315
347
  dispatcher,
316
348
  replyOptions: {
317
349
  ...replyOptions,
350
+ abortSignal: dispatchAbort!.signal,
318
351
  onModelSelected: prefixContext.onModelSelected,
319
352
  onAgentRunStart: (runId) => {
320
- activeRunIdBySessionKey.set(dcgSessionKey, runId)
353
+ setActiveRunIdForSession(dcgSessionKey, runId)
321
354
  }
322
355
  }
323
356
  })
324
357
  })
325
- } else if (interruptCommand.includes(text?.trim())) {
326
- dcgLogger(`interrupt command: ${text}`)
358
+ } else if (stopCommand.includes(text?.trim())) {
327
359
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
328
- sendFinal(ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }, 'abort')
329
- sendText('会话已终止', outboundCtx)
330
- sessionStreamSuppressed.add(dcgSessionKey)
331
- const abortOneSession = async (sessionKey: string) => {
332
- try {
333
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
334
- } catch (e) {
335
- dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
336
- }
337
- }
338
- const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(dcgSessionKey))
339
- try {
340
- const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
341
- method: 'sessions.list',
342
- params: { spawnedBy: dcgSessionKey, limit: 256 }
343
- })
344
- for (const s of listed?.sessions ?? []) {
345
- const k = typeof s?.key === 'string' ? s.key.trim() : ''
346
- if (k) keysToAbort.add(k)
347
- }
348
- } catch (e) {
349
- dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
350
- }
351
- for (const sk of keysToAbort) {
352
- await abortOneSession(sk)
353
- }
354
- await abortOneSession(dcgSessionKey)
360
+ await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
355
361
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
356
- resetSubagentStateForRequesterSession(dcgSessionKey)
357
- setMsgStatus(dcgSessionKey, 'finished')
358
362
  clearSentMediaKeys(msg.content.message_id)
363
+ setMsgStatus(dcgSessionKey, 'finished')
359
364
  clearParamsMessage(dcgSessionKey)
360
- clearParamsMessage(userId)
365
+ sendText('会话已终止', outboundCtx)
361
366
  sendFinal(outboundCtx, 'stop')
362
367
  return
363
368
  } else {
@@ -387,29 +392,35 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
387
392
  dispatcher,
388
393
  replyOptions: {
389
394
  ...replyOptions,
395
+ abortSignal: dispatchAbort!.signal,
390
396
  onModelSelected: prefixContext.onModelSelected,
391
397
  onAgentRunStart: (runId) => {
392
- activeRunIdBySessionKey.set(dcgSessionKey, runId)
398
+ setActiveRunIdForSession(dcgSessionKey, runId)
393
399
  },
394
400
  onPartialReply: async (payload: ReplyPayload) => {
395
- if (sessionStreamSuppressed.has(dcgSessionKey)) return
401
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
396
402
 
397
- if (payload.text) {
398
- completeText = payload.text
399
- }
400
403
  // --- Streaming text chunks ---
401
404
  if (payload.text) {
402
- const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
403
- ? payload.text.slice(streamedTextLen)
404
- : payload.text
405
+ const t = payload.text
406
+ let delta = ''
407
+ if (t.startsWith(lastStreamSnapshot)) {
408
+ delta = t.slice(lastStreamSnapshot.length)
409
+ lastStreamSnapshot = t
410
+ } else if (lastStreamSnapshot.startsWith(t)) {
411
+ // 快照缩短(模型修订等):不重复下发
412
+ } else {
413
+ // 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
414
+ delta = t
415
+ lastStreamSnapshot = t
416
+ }
405
417
  if (delta.trim()) {
406
418
  const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
407
419
  streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
408
420
  sendChunk(delta, outboundCtx, prev)
409
421
  }
410
- streamedTextLen = payload.text.length
411
422
  } else {
412
- dcgLogger(`onPartialReply no text: ${JSON.stringify(payload)}`, 'error')
423
+ dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
413
424
  }
414
425
  // --- Media from payload ---
415
426
  const mediaList = resolveReplyMediaList(payload)
@@ -425,17 +436,37 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
425
436
  })
426
437
  }
427
438
  } catch (err: unknown) {
428
- dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
439
+ dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
440
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
441
+ setMsgStatus(dcgSessionKey, 'finished')
442
+ }
443
+ } finally {
444
+ releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
429
445
  }
430
446
 
431
- if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
432
- if (sessionStreamSuppressed.has(dcgSessionKey)) {
433
- sessionStreamSuppressed.delete(dcgSessionKey)
447
+ const inboundGenNow = inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0
448
+ if (inboundGenNow !== inboundGenAtStart) {
449
+ dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
450
+ return
451
+ }
452
+
453
+ if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
454
+ if (isSessionStreamSuppressed(dcgSessionKey)) {
455
+ clearSessionStreamSuppression(dcgSessionKey)
434
456
  }
435
457
  }
436
458
  clearSentMediaKeys(msg.content.message_id)
437
459
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
438
460
  await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
461
+ // 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
462
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
463
+ dcgLogger(
464
+ `skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${inboundGenerationBySessionKey.get(dcgSessionKey)})`
465
+ )
466
+ return
467
+ }
468
+ clearActiveRunIdForSession(dcgSessionKey)
469
+ setMsgStatus(dcgSessionKey, 'finished')
439
470
  sendFinal(outboundCtx, 'end')
440
471
  dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
441
472
  core.channel.session
@@ -458,6 +489,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
458
489
  })
459
490
  } catch (err) {
460
491
  dcgLogger(` handle message failed: ${String(err)}`, 'error')
492
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
493
+ setMsgStatus(dcgSessionKey, 'finished')
494
+ }
461
495
  sendError(err instanceof Error ? err.message : String(err), outboundCtx)
462
496
  }
463
497
  }
package/src/channel.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs'
1
2
  import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
2
3
  import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
3
4
  import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
@@ -8,12 +9,15 @@ import {
8
9
  getDcgchatRuntime,
9
10
  getInfoBySessionKey,
10
11
  getOpenClawConfig,
11
- hasSentMediaKey
12
+ hasSentMediaKey,
13
+ setCronMessageId
12
14
  } from './utils/global.js'
13
15
  import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
14
16
  import { dcgLogger, setLogger } from './utils/log.js'
15
17
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
18
+ import { isSessionActiveForTool } from './tool.js'
16
19
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
20
+ import { getCronJobsPath, readCronJob } from './cron.js'
17
21
 
18
22
  function dcgchatChannelCfg(): DcgchatConfig {
19
23
  return (getOpenClawConfig()?.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
@@ -61,6 +65,8 @@ export type DcgchatMediaSendOptions = {
61
65
  sessionKey: string
62
66
  mediaUrl?: string
63
67
  text?: string
68
+ /** 定时任务等场景须与 `getCronMessageId` 一致,避免沿用 map 里上一条用户消息的 messageId */
69
+ messageId?: string
64
70
  }
65
71
 
66
72
  function normalizeSessionTarget(rawTo: string): string {
@@ -69,6 +75,29 @@ function normalizeSessionTarget(rawTo: string): string {
69
75
  return getParamsMessage(cleaned)?.sessionKey?.trim() || cleaned
70
76
  }
71
77
 
78
+ /**
79
+ * OpenClaw 定时任务 `sessionTarget: isolated` 时,出站 `to` 常为 `agent:<code>:cron:<jobId>[:run:…]`,
80
+ * 与 `paramsMessageMap` / `getCronMessageId` 使用的 jobs.json `sessionKey`(mobook 用户会话)不一致,导致发文件时 sessionId、messageId 错位或缺省。
81
+ */
82
+ function extractCronJobIdFromIsolatedSessionKey(sessionKey: string): string | null {
83
+ const parts = sessionKey.split(':').filter((p) => p.length > 0)
84
+ const i = parts.findIndex((p) => p.toLowerCase() === 'cron')
85
+ if (i < 0 || i + 1 >= parts.length) return null
86
+ return parts[i + 1] ?? null
87
+ }
88
+
89
+ function resolveIsolatedCronSessionToJobSessionKey(sessionKey: string): string {
90
+ const jobId = extractCronJobIdFromIsolatedSessionKey(sessionKey)
91
+ if (!jobId) return sessionKey
92
+ const job = readCronJob(getCronJobsPath(), jobId)
93
+ const sk = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
94
+ if (!sk) {
95
+ dcgLogger(`dcgchat: cron job ${jobId} has no sessionKey in jobs.json, keep outbound key=${sessionKey}`, 'error')
96
+ return sessionKey
97
+ }
98
+ return sk
99
+ }
100
+
72
101
  /** 出站返回的 chatId:含 `dcg-cron:` 时保留原始 `to`,便于下游识别定时投递 */
73
102
  function outboundChatId(rawTo: string | undefined, normalizedTo: string): string {
74
103
  const raw = rawTo?.trim() ?? ''
@@ -76,38 +105,78 @@ function outboundChatId(rawTo: string | undefined, normalizedTo: string): string
76
105
  }
77
106
 
78
107
  export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
79
- const sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
80
- const msgCtx = getOutboundMsgParams(sessionKey)
108
+ const rawOpt = (opts.sessionKey ?? '').trim()
109
+ const strippedForCron = rawOpt.replace(/^dcg-cron:/i, '').trim()
110
+ const fromIsolatedCron = extractCronJobIdFromIsolatedSessionKey(strippedForCron) !== null
111
+ const fromDcgCronWrapper = rawOpt.toLowerCase().startsWith('dcg-cron:')
112
+
113
+ let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
114
+ sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
115
+
116
+ /** 定时自动执行未走 onRunCronJob,须与 finishedDcgchatCron 共用同一 messageId,否则附件与气泡错位 */
117
+ if (!opts.messageId?.trim() && (fromIsolatedCron || fromDcgCronWrapper) && !getCronMessageId(sessionKey)) {
118
+ setCronMessageId(sessionKey, `${Date.now()}`)
119
+ }
120
+
121
+ const cronMid = getCronMessageId(sessionKey)
122
+ const baseCtx = getOutboundMsgParams(sessionKey)
123
+ const msgCtx = opts.messageId?.trim()
124
+ ? { ...baseCtx, messageId: opts.messageId.trim() }
125
+ : cronMid
126
+ ? { ...baseCtx, messageId: cronMid }
127
+ : baseCtx
81
128
  if (!isWsOpen()) {
82
- dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
129
+ dcgLogger(`outbound media skipped -> ws not isWsOpen failed open: ${opts.mediaUrl ?? ''}`)
83
130
  return
84
131
  }
85
132
 
86
- const mediaUrl = opts.mediaUrl
87
- const dedupeId = msgCtx.messageId
88
- if (mediaUrl && dedupeId && hasSentMediaKey(dedupeId, mediaUrl)) {
89
- dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl}`)
133
+ const mediaUrl = opts.mediaUrl?.trim()
134
+ if (!mediaUrl || !msgCtx.sessionId) {
135
+ dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
90
136
  return
91
137
  }
92
- if (mediaUrl && dedupeId) {
93
- addSentMediaKey(dedupeId, mediaUrl)
138
+ // 判断文件存在
139
+ try {
140
+ if (!fs.existsSync(mediaUrl)) {
141
+ dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
142
+ return
143
+ }
144
+ } catch (err) {
145
+ dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
94
146
  }
95
147
 
96
- const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
148
+ if (mediaUrl && msgCtx.sessionId) {
149
+ if (hasSentMediaKey(msgCtx.sessionId, mediaUrl)) {
150
+ dcgLogger(`dcgchat: sendMedia skipped (hasSentMediaKey): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
151
+ return
152
+ }
153
+ addSentMediaKey(msgCtx.sessionId, mediaUrl)
154
+ }
97
155
 
156
+ const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
157
+ if (!msgCtx.sessionId) {
158
+ msgCtx.sessionId = sessionId
159
+ }
160
+ const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
161
+ const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
98
162
  try {
99
163
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat"]?.botToken ?? ''
100
164
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
165
+ if (!msgCtx.agentId) {
166
+ msgCtx.agentId = agentId
167
+ }
101
168
  wsSendRaw(msgCtx, {
102
169
  response: opts.text ?? '',
103
- message_tags: { source: 'file' },
170
+ is_finish: notMessageId ? -1 : 0,
171
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
104
172
  files: [{ url, name: fileName }]
105
173
  })
106
174
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
107
175
  } catch (error) {
108
176
  wsSendRaw(msgCtx, {
109
177
  response: opts.text ?? '',
110
- message_tags: { source: 'file' },
178
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
179
+ is_finish: notMessageId ? -1 : 0,
111
180
  files: [{ url: opts.mediaUrl ?? '', name: fileName }]
112
181
  })
113
182
  dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
@@ -254,25 +323,21 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
254
323
  const isCron = ctx.to.indexOf('dcg-cron:') >= 0
255
324
  const outboundCtx = getOutboundMsgParams(to)
256
325
  const content: Record<string, unknown> = { response: ctx.text }
257
- if (isCron) {
258
- messageId = getCronMessageId(to) || `${Date.now()}`
259
- const { sessionId, agentId } = getInfoBySessionKey(to)
260
- content.is_finish = -1
261
- content.message_tags = { source: 'cron' }
262
- const merged = mergeDefaultParams({
263
- agentId: agentId,
264
- sessionId: `${sessionId}`,
265
- messageId: messageId,
266
- real_mobook: !sessionId ? 1 : ''
267
- })
268
- wsSendRaw(merged, content)
269
- } else {
326
+ if (!isCron) {
270
327
  if (outboundCtx?.sessionId) {
328
+ // 入站 handler 已将本轮标为 running;若已 sendFinal(end) 则应为 finished。
329
+ // 否则网关/Core 晚到的正文会仍用「当前 params map」里的 messageId,错挂到后一条用户消息上。
330
+ if (!isSessionActiveForTool(to)) {
331
+ dcgLogger(`channel sendText dropped (session not active): to=${to}`)
332
+ return {
333
+ channel: "dcgchat",
334
+ messageId: '',
335
+ chatId: outboundChatId(ctx.to, to)
336
+ }
337
+ }
271
338
  messageId = outboundCtx?.messageId || `${Date.now()}`
272
339
  const newCtx = { ...outboundCtx, messageId }
273
340
  wsSendRaw(newCtx, content)
274
- } else {
275
- dcgLogger(`channel sendText to ${ctx.to} -> sessionId not found`, 'error')
276
341
  }
277
342
  }
278
343
  }
@@ -283,15 +348,19 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
283
348
  }
284
349
  },
285
350
  sendMedia: async (ctx) => {
286
- const isCron = ctx.to.indexOf('dcg-cron:') >= 0
287
- const to = normalizeSessionTarget(ctx.to)
351
+ const normalizedFromTo = normalizeSessionTarget(ctx.to)
352
+ const isCron = ctx.to.indexOf('dcg-cron:') >= 0 || extractCronJobIdFromIsolatedSessionKey(normalizedFromTo) !== null
353
+ const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
288
354
  const outboundCtx = getOutboundMsgParams(to)
289
355
  const msgCtx = getParamsMessage(to) ?? outboundCtx
356
+ if (isCron && !getCronMessageId(to)) {
357
+ setCronMessageId(to, `${Date.now()}`)
358
+ }
290
359
  const cronMsgId = getCronMessageId(to)
291
360
  const fallbackMessageId = `${Date.now()}`
292
361
  const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
293
-
294
- if (!outboundCtx?.sessionId) {
362
+ const { sessionId } = getInfoBySessionKey(to)
363
+ if (!sessionId) {
295
364
  dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
296
365
  return {
297
366
  channel: "dcgchat",
@@ -301,7 +370,11 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
301
370
  }
302
371
 
303
372
  dcgLogger(`channel sendMedia to ${ctx.to}`)
304
- await sendDcgchatMedia({ sessionKey: to || '', mediaUrl: ctx.mediaUrl || '' })
373
+ await sendDcgchatMedia({
374
+ sessionKey: to || '',
375
+ mediaUrl: ctx.mediaUrl || '',
376
+ ...(isCron ? { messageId } : {})
377
+ })
305
378
  return {
306
379
  channel: "dcgchat",
307
380
  messageId,
package/src/cron.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import fs from 'node:fs'
3
3
  import type { IMsgParams } from './types.js'
4
- import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal } from './transport.js'
4
+ import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal, wsSendRaw } from './transport.js'
5
5
  import { getCronMessageId, getWorkspaceDir, getWsConnection, removeCronMessageId, setCronMessageId } from './utils/global.js'
6
6
  import { ossUpload } from './request/oss.js'
7
7
  import { dcgLogger } from './utils/log.js'
@@ -135,7 +135,7 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
135
135
  )
136
136
  sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
137
137
  }
138
- export const finishedDcgchatCron = async (jobId: string) => {
138
+ export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
139
139
  const id = jobId?.trim()
140
140
  if (!id) {
141
141
  dcgLogger('finishedDcgchatCron: empty jobId', 'error')
@@ -147,24 +147,27 @@ export const finishedDcgchatCron = async (jobId: string) => {
147
147
  dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
148
148
  return
149
149
  }
150
- const outboundCtx = getEffectiveMsgParams(sessionKey)
151
- const messageId = getCronMessageId(sessionKey)
150
+ const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
152
151
  const sessionInfo = sessionKey.split(':')
153
152
  const sessionId = sessionInfo.at(-1) ?? ''
154
153
  const agentId = sessionInfo.at(-2) ?? ''
155
- if (outboundCtx?.sessionId) {
156
- const newCtx = messageId ? { ...outboundCtx, messageId } : outboundCtx
157
- sendFinal(newCtx, 'cron send')
158
- } else {
159
- const merged = mergeDefaultParams({
160
- agentId: agentId,
161
- sessionId: `${sessionId}`,
162
- messageId: messageId,
163
- is_finish: -1,
164
- real_mobook: !sessionId ? 1 : ''
165
- })
166
- sendFinal(merged, 'cron send')
154
+ // if (outboundCtx?.sessionId) {
155
+
156
+ const merged = mergeDefaultParams({
157
+ agentId: agentId,
158
+ sessionId: `${sessionId}`,
159
+ messageId: messageId || `${Date.now()}`,
160
+ real_mobook: !sessionId ? 1 : ''
161
+ })
162
+ const message_tags = { source: 'cron' } as Record<string, string | boolean>
163
+ if (hasFileOutput) {
164
+ message_tags.hasFile = true
167
165
  }
166
+ wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
167
+ setTimeout(() => {
168
+ sendFinal(merged, 'cron send')
169
+ }, 200)
170
+ // }
168
171
  const ws = getWsConnection()
169
172
  const baseContent = getParamsDefaults()
170
173
  if (isWsOpen()) {
@@ -117,34 +117,44 @@ function needsBestEffort(delivery: CronDelivery): boolean {
117
117
  }
118
118
 
119
119
  /**
120
- * 深拷贝 params 并注入 bestEffort: true
120
+ * 深拷贝 params,在 delivery 上写入 dcg 路由(to / accountId / sessionKey)。
121
+ * bestEffort + 默认 channel 仅在 needsBestEffort 为真时写入(announce 且无 channel)。
122
+ *
123
+ * 说明:原先仅在 needsBestEffort 为真时才改 delivery,若 jobs 里已有 channel 会整段跳过,
124
+ * 导致 `delivery.to` 永远不会被本钩子写入;运行时若缺 to 就会一直 not-delivered。
121
125
  */
122
- function injectBestEffort(params: Record<string, unknown>, sk: string): Record<string, unknown> {
126
+ function patchCronDeliveryInParams(
127
+ params: Record<string, unknown>,
128
+ sk: string,
129
+ deliverySnapshot: CronDelivery
130
+ ): Record<string, unknown> | null {
123
131
  const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
124
- const { agentId, sessionId } = formatterSessionKey(sk)
125
- // 顶层 delivery
132
+ const { agentId } = formatterSessionKey(sk)
133
+ const announceNoChannel = needsBestEffort(deliverySnapshot)
134
+
135
+ const apply = (d: CronDelivery) => {
136
+ d.to = `dcg-cron:${sk}`
137
+ if (agentId) d.accountId = agentId
138
+ if (announceNoChannel) {
139
+ d.bestEffort = true
140
+ d.channel = 'dcgchat-test'
141
+ }
142
+ }
143
+
126
144
  if (newParams.delivery && typeof newParams.delivery === 'object') {
127
- ;(newParams.delivery as CronDelivery).bestEffort = true
128
- ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
129
- ;(newParams.delivery as CronDelivery).accountId = agentId
130
- ;(newParams.delivery as CronDelivery).channel = "dcgchat"
145
+ apply(newParams.delivery as CronDelivery)
131
146
  newParams.sessionKey = sk
132
147
  return newParams
133
148
  }
134
149
 
135
- // job.delivery(须改 job 内对象,勿写 newParams.delivery——顶层可能不存在)
136
150
  const job = newParams.job as Record<string, unknown> | undefined
137
151
  if (job?.delivery && typeof job.delivery === 'object') {
138
- const del = job.delivery as CronDelivery
139
- del.bestEffort = true
140
- del.to = `dcg-cron:${sk}`
141
- del.accountId = agentId
142
- del.channel = "dcgchat"
152
+ apply(job.delivery as CronDelivery)
143
153
  newParams.sessionKey = sk
144
154
  return newParams
145
155
  }
146
156
 
147
- return newParams
157
+ return null
148
158
  }
149
159
 
150
160
  export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
@@ -155,34 +165,38 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
155
165
  const delivery = extractDelivery(params)
156
166
  if (!delivery) {
157
167
  dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
158
- return params
168
+ return undefined
169
+ }
170
+ const newParams = patchCronDeliveryInParams(params, sk, delivery)
171
+ if (!newParams) {
172
+ dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) could not locate delivery on params clone, skip.`)
173
+ return undefined
159
174
  }
160
- if (!needsBestEffort(delivery)) {
175
+
176
+ const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
177
+ if (needsBestEffort(delivery)) {
161
178
  dcgLogger(
162
- `[${LOG_TAG}] cron call (${toolCallId}) delivery does not need bestEffort ` +
163
- `(mode=${String(delivery.mode)}, channel=${String(delivery.channel)}, bestEffort=${String(delivery.bestEffort)}), skip.`
179
+ `[${LOG_TAG}] cron call (${toolCallId}) patched delivery (announce, no channel): bestEffort=true, ` +
180
+ `delivery=${JSON.stringify(patched)}`
181
+ )
182
+ } else {
183
+ dcgLogger(
184
+ `[${LOG_TAG}] cron call (${toolCallId}) patched delivery.to / accountId (sessionKey=${sk}), ` +
185
+ `delivery=${JSON.stringify(patched)}`
164
186
  )
165
- return params
166
187
  }
167
188
 
168
- // ★ 核心:注入 bestEffort: true
169
- const newParams = injectBestEffort(params, sk)
170
- dcgLogger(
171
- `[${LOG_TAG}] cron call (${toolCallId}) injected bestEffort=true ` +
172
- `(mode=announce, no channel). delivery=${JSON.stringify(newParams.delivery || (newParams.job as Record<string, unknown>)?.delivery)}`
173
- )
174
-
175
189
  return { params: newParams }
176
190
  } else if (toolName === 'exec') {
177
191
  if (params.command.indexOf('cron create') > -1 || params.command.indexOf('cron add') > -1) {
178
192
  const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
179
193
  newParams.command =
180
- params.command.replace('--json', '') + ` --session-key ${sk} --channel ${"dcgchat"} --to dcg-cron:${sk} --json`
194
+ params.command.replace('--json', '') + ` --session-key ${sk} --channel ${'dcgchat-test'} --to dcg-cron:${sk} --json`
181
195
  return { params: newParams }
182
196
  } else {
183
- return params
197
+ return undefined
184
198
  }
185
199
  }
186
200
 
187
- return params
201
+ return undefined
188
202
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
3
+ * 与 bot 的 generation、流式分片序号、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
4
+ * 入站串行队尾、activeRunId、chat.abort 子会话→主会话。
5
+ */
6
+ import { sendGatewayRpc } from './gateway/socket.js'
7
+ import { sendFinal } from './transport.js'
8
+ import type { IMsgParams } from './types.js'
9
+ import { dcgLogger } from './utils/log.js'
10
+ import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession } from './tool.js'
11
+
12
+ // --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
13
+
14
+ /** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
15
+ const activeRunIdBySessionKey = new Map<string, string>()
16
+
17
+ /** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
18
+ const dispatchAbortBySessionKey = new Map<string, AbortController>()
19
+
20
+ /** 打断后抑制 deliver / onPartialReply 继续下发 */
21
+ const sessionStreamSuppressed = new Set<string>()
22
+
23
+ /**
24
+ * 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
25
+ */
26
+ const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
27
+
28
+ // --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
29
+
30
+ export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
31
+ activeRunIdBySessionKey.set(sessionKey, runId)
32
+ }
33
+
34
+ export function clearActiveRunIdForSession(sessionKey: string): void {
35
+ activeRunIdBySessionKey.delete(sessionKey)
36
+ }
37
+
38
+ // --- 流抑制 ---
39
+
40
+ export function isSessionStreamSuppressed(sessionKey: string): boolean {
41
+ return sessionStreamSuppressed.has(sessionKey)
42
+ }
43
+
44
+ export function clearSessionStreamSuppression(sessionKey: string): void {
45
+ sessionStreamSuppressed.delete(sessionKey)
46
+ }
47
+
48
+ export function markSessionStreamSuppressed(sessionKey: string): void {
49
+ sessionStreamSuppressed.add(sessionKey)
50
+ }
51
+
52
+ // --- 入站串行队列 ---
53
+
54
+ /** /stop 入队前:掐断当前 in-process,并重置队尾,使本条 stop 不必等待已被 abort 的长 turn */
55
+ export function preemptInboundQueueForStop(sessionKey: string): void {
56
+ const c = dispatchAbortBySessionKey.get(sessionKey)
57
+ if (c) {
58
+ c.abort()
59
+ dispatchAbortBySessionKey.delete(sessionKey)
60
+ }
61
+ inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
62
+ dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
63
+ }
64
+
65
+ /** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
66
+ export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
67
+ const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
68
+ const next = prev.catch(() => {}).then(run)
69
+ inboundTurnTailBySessionKey.set(sessionKey, next.catch(() => {}))
70
+ await next
71
+ }
72
+
73
+ // --- 网关 abort ---
74
+
75
+ /**
76
+ * 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
77
+ * supersede:仅当有子会话或 mainRunId 时发 RPC;interrupt:主会话始终 chat.abort。
78
+ */
79
+ export async function abortGatewayRunsForSession(sessionKey: string, reason: 'interrupt' | 'supersede'): Promise<void> {
80
+ const prefix = reason === 'interrupt' ? 'interrupt' : 'supersede'
81
+ const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
82
+ const abortSubKeys = [...descendantKeys].reverse()
83
+ const mainRunId = activeRunIdBySessionKey.get(sessionKey)
84
+
85
+ if (reason === 'supersede' && abortSubKeys.length === 0 && !mainRunId) {
86
+ return
87
+ }
88
+
89
+ if (abortSubKeys.length > 0) {
90
+ dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
91
+ }
92
+ for (const subKey of abortSubKeys) {
93
+ try {
94
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
95
+ } catch (e) {
96
+ dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
97
+ }
98
+ }
99
+
100
+ const shouldMainAbort = reason === 'interrupt' || Boolean(mainRunId)
101
+ if (shouldMainAbort) {
102
+ try {
103
+ await sendGatewayRpc({
104
+ method: 'chat.abort',
105
+ params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
106
+ })
107
+ } catch (e) {
108
+ dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
109
+ }
110
+ }
111
+
112
+ activeRunIdBySessionKey.delete(sessionKey)
113
+ resetSubagentStateForRequesterSession(sessionKey)
114
+ }
115
+
116
+ // --- 本地 dispatch AbortController ---
117
+
118
+ /**
119
+ * 新一轮非 /stop 用户消息:清除流抑制、abort 上一轮 controller、网关 supersede,并安装新的 AbortController。
120
+ * 调用方须在之前或之后自行 `streamChunkIdxBySessionKey.set(sessionKey, 0)`。
121
+ */
122
+ export async function beginSupersedingUserTurn(sessionKey: string): Promise<AbortController> {
123
+ sessionStreamSuppressed.delete(sessionKey)
124
+ dispatchAbortBySessionKey.get(sessionKey)?.abort()
125
+ await abortGatewayRunsForSession(sessionKey, 'supersede')
126
+ const ac = new AbortController()
127
+ dispatchAbortBySessionKey.set(sessionKey, ac)
128
+ return ac
129
+ }
130
+
131
+ /** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
132
+ export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
133
+ if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
134
+ dispatchAbortBySessionKey.delete(sessionKey)
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 用户发送 /stop:本地 abort、对「上一轮对话」发 abort final、标记流抑制、网关 interrupt。
140
+ * 之后的 clearSentMedia、params、UI「已终止」等由 bot 继续处理。
141
+ */
142
+ export async function interruptLocalDispatchAndGateway(sessionKey: string, ctxForAbort: IMsgParams): Promise<void> {
143
+ dcgLogger(`interrupt command: sessionKey=${sessionKey}`)
144
+ const inFlight = dispatchAbortBySessionKey.get(sessionKey)
145
+ if (inFlight) {
146
+ dcgLogger(`interrupt: AbortController.abort() in-process run sessionKey=${sessionKey}`)
147
+ inFlight.abort()
148
+ dispatchAbortBySessionKey.delete(sessionKey)
149
+ }
150
+ const finalCtx = ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }
151
+ sendFinal(finalCtx, 'abort')
152
+ markSessionStreamSuppressed(sessionKey)
153
+ await abortGatewayRunsForSession(sessionKey, 'interrupt')
154
+ }
package/src/tool.ts CHANGED
@@ -51,8 +51,30 @@ const eventList = [
51
51
  { event: 'after_tool_call', message: '' }
52
52
  ]
53
53
 
54
+ /** 子 agent 的 sessionKey 往往未写入 params map,回落到主会话 outbound 参数避免 messageId 缺失 */
55
+ function resolveOutboundParamsForSession(sk: string) {
56
+ const k = sk.trim()
57
+ let params = getEffectiveMsgParams(k)
58
+ if (params.messageId?.trim() || params.sessionId?.trim()) return params
59
+ const parent = requesterByChildSessionKey.get(k)
60
+ if (parent) {
61
+ const parentParams = getEffectiveMsgParams(parent)
62
+ if (parentParams.messageId?.trim() || parentParams.sessionId?.trim()) return parentParams
63
+ }
64
+ return params
65
+ }
66
+
67
+ /** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
68
+ export function isSessionActiveForTool(sk: string): boolean {
69
+ const k = sk.trim()
70
+ if (!k) return false
71
+ if (getMsgStatus(k) === 'running') return true
72
+ const parent = requesterByChildSessionKey.get(k)
73
+ return parent ? getMsgStatus(parent) === 'running' : false
74
+ }
75
+
54
76
  function sendToolCallMessage(sk: string, text: string, toolCallId: string, isCover: number) {
55
- const params = getEffectiveMsgParams(sk)
77
+ const params = resolveOutboundParamsForSession(sk)
56
78
  const content = { is_finish: -1, tool_call_id: toolCallId, is_cover: isCover, thinking_content: text, response: '' }
57
79
  wsSendRaw(params, content, false)
58
80
  }
@@ -158,6 +180,29 @@ export function getChildSessionKeysTrackedForRequester(requesterSessionKey: stri
158
180
  return out
159
181
  }
160
182
 
183
+ /**
184
+ * 自根 requester 起 BFS 收集所有已跟踪后代子会话(含嵌套)。网关 abort 时宜自深到浅,调用方对结果 `.reverse()` 后再逐个 chat.abort。
185
+ */
186
+ export function getDescendantSessionKeysForRequester(rootRequesterSessionKey: string): string[] {
187
+ const root = rootRequesterSessionKey.trim()
188
+ if (!root) return []
189
+ const ordered: string[] = []
190
+ const seen = new Set<string>()
191
+ let frontier = getChildSessionKeysTrackedForRequester(root)
192
+ while (frontier.length > 0) {
193
+ const next: string[] = []
194
+ for (const sk of frontier) {
195
+ const k = sk.trim()
196
+ if (!k || seen.has(k)) continue
197
+ seen.add(k)
198
+ ordered.push(k)
199
+ next.push(...getChildSessionKeysTrackedForRequester(k))
200
+ }
201
+ frontier = next
202
+ }
203
+ return ordered
204
+ }
205
+
161
206
  /**
162
207
  * 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
163
208
  */
@@ -280,6 +325,16 @@ function resolveHookSessionKey(
280
325
  return (args?.sessionKey || '').trim()
281
326
  }
282
327
 
328
+ /** 定时触发时会话往往非 running,但仍需跑 before_tool_call 以注入 sessionKey / delivery(见 cronToolCall) */
329
+ function shouldRunBeforeToolCallWithoutRunningSession(event: { toolName?: string; params?: { command?: string } }): boolean {
330
+ if (event?.toolName === 'cron') return true
331
+ const cmd = event?.params?.command
332
+ if (event?.toolName === 'exec' && typeof cmd === 'string') {
333
+ return cmd.includes('cron create') || cmd.includes('cron add')
334
+ }
335
+ return false
336
+ }
337
+
283
338
  function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
284
339
  if (eventName === 'subagent_spawned') {
285
340
  const runId = typeof event?.runId === 'string' ? event.runId : ''
@@ -306,14 +361,24 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
306
361
  trackSubagentLifecycle(item.event, event, args)
307
362
  const sk = resolveHookSessionKey(item.event, args ?? {})
308
363
  if (sk) {
309
- const status = getMsgStatus(sk)
310
- if (status === 'running') {
364
+ const toolHooksOk =
365
+ isSessionActiveForTool(sk) || (item.event === 'before_tool_call' && shouldRunBeforeToolCallWithoutRunningSession(event))
366
+ if (toolHooksOk) {
311
367
  if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
312
368
  const { result: _result, ...rest } = event
313
369
  dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
314
370
 
315
371
  if (item.event === 'before_tool_call') {
316
- return cronToolCall(rest, sk)
372
+ const hookResult = cronToolCall(rest, sk)
373
+ const text = JSON.stringify({
374
+ type: item.event,
375
+ specialIdentification: 'dcgchat_tool_call_special_identification',
376
+ callId: event.toolCallId || event.runId || Date.now().toString(),
377
+ ...rest,
378
+ status: 'running'
379
+ })
380
+ sendToolCallMessage(sk, text, event.toolCallId || event.runId || Date.now().toString(), 0)
381
+ return hookResult
317
382
  }
318
383
  const text = JSON.stringify({
319
384
  type: item.event,
@@ -329,7 +394,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
329
394
  item.event === 'after_tool_call' ? 1 : 0
330
395
  )
331
396
  } else if (item.event) {
332
- const msgCtx = getEffectiveMsgParams(sk)
397
+ const msgCtx = resolveOutboundParamsForSession(sk)
333
398
  if (item.event === 'llm_output') {
334
399
  if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
335
400
  const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
@@ -346,7 +411,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
346
411
  params: item.message
347
412
  })
348
413
  sendToolCallMessage(sk, text, event.runId || Date.now().toString(), 0)
349
- dcgLogger(`工具调用结果: ~ event:${item.event} ${status}`)
414
+ dcgLogger(`工具调用结果: ~ event:${item.event}`)
350
415
  }
351
416
  }
352
417
  } else if (item.event !== 'before_tool_call') {
package/src/transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getWsConnection } from './utils/global.js'
1
+ import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
2
2
  import { dcgLogger } from './utils/log.js'
3
3
  import type { IMsgParams } from './types.js'
4
4
  import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
@@ -170,6 +170,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
170
170
 
171
171
  export function sendFinal(ctx: IMsgParams, tag: string): boolean {
172
172
  dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
173
+ clearSentMediaKeys(ctx.sessionId)
173
174
  return wsSend(ctx, { response: '', state: 'final' })
174
175
  }
175
176
 
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'production'
2
2
 
3
3
 
4
4
  export const systemCommand = ['/new', '/status']
5
- export const interruptCommand = ['/stop']
5
+ export const stopCommand = ['/stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...stopCommand]
@@ -1,14 +1,17 @@
1
1
  import type { GatewayEvent } from '../gateway/index.js'
2
2
  import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
3
3
  import { dcgLogger } from './log.js'
4
- import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
- import { sendChunk } from '../transport.js'
4
+ import { clearParamsMessage, getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
+ import { sendChunk, sendFinal, sendText } from '../transport.js'
6
+ import { resetSubagentStateForRequesterSession } from '../tool.js'
7
+ import { setMsgStatus } from './global.js'
6
8
 
7
9
  /**
8
10
  * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
9
11
  */
10
12
  export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
11
13
  try {
14
+ // 子agent消息输出
12
15
  if (msg.event === 'agent') {
13
16
  const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
14
17
  const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
@@ -19,6 +22,7 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
19
22
  }
20
23
  }
21
24
  }
25
+ // 定时任务
22
26
  if (msg.event === 'cron') {
23
27
  const p = msg.payload
24
28
  dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
@@ -31,8 +35,13 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
31
35
  if (p?.action === 'removed') {
32
36
  sendDcgchatCron(p?.jobId as string)
33
37
  }
34
- if (p?.action === 'finished') {
35
- finishedDcgchatCron(p?.jobId as string)
38
+ if (p?.action === 'finished' && p?.status === 'ok') {
39
+ const hasFileOutput = p.delivered === true && p.deliveryStatus === 'delivered'
40
+ let summary = p?.summary as string
41
+ if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
42
+ summary = summary.replace('HEARTBEAT_OK', '')
43
+ }
44
+ finishedDcgchatCron(p?.jobId as string, summary, hasFileOutput)
36
45
  }
37
46
  }
38
47
  } catch (error) {
@@ -82,11 +82,11 @@ const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
82
82
  /** 已发送媒体去重:外层 messageId → 内层该会话下已发送的媒体 key(文件名) */
83
83
  const sentMediaKeysBySession = new Map<string, Set<string>>()
84
84
 
85
- function getSessionMediaSet(messageId: string): Set<string> {
86
- let set = sentMediaKeysBySession.get(messageId)
85
+ function getSessionMediaSet(sessionId: string): Set<string> {
86
+ let set = sentMediaKeysBySession.get(sessionId)
87
87
  if (!set) {
88
88
  set = new Set<string>()
89
- sentMediaKeysBySession.set(messageId, set)
89
+ sentMediaKeysBySession.set(sessionId, set)
90
90
  }
91
91
  return set
92
92
  }
@@ -95,31 +95,48 @@ export function addSentMediaKey(messageId: string, url: string) {
95
95
  getSessionMediaSet(messageId).add(getMediaKey(url))
96
96
  }
97
97
 
98
- export function hasSentMediaKey(messageId: string, url: string): boolean {
99
- return sentMediaKeysBySession.get(messageId)?.has(getMediaKey(url)) ?? false
98
+ export function hasSentMediaKey(sessionId: string, url: string): boolean {
99
+ return sentMediaKeysBySession.get(sessionId)?.has(getMediaKey(url)) ?? false
100
100
  }
101
101
 
102
102
  /** 不传 messageId 时清空全部会话;传入则只清空该会话 */
103
- export function clearSentMediaKeys(messageId?: string) {
104
- if (messageId) {
105
- sentMediaKeysBySession.delete(messageId)
103
+ export function clearSentMediaKeys(sessionId?: string) {
104
+ if (sessionId) {
105
+ sentMediaKeysBySession.delete(sessionId)
106
106
  } else {
107
107
  sentMediaKeysBySession.clear()
108
108
  }
109
109
  }
110
110
 
111
- const cronMessageIdMap = new Map<string, string>()
111
+ /** 每个 sessionKey 下多个定时任务 messageId,入队在尾、出队在头(FIFO) */
112
+ const cronMessageIdMap = new Map<string, string[]>()
112
113
 
113
- export function setCronMessageId(sk: string, messageId: string) {
114
- cronMessageIdMap.set(sk, messageId)
114
+ export function setCronMessageId(sk: string, messageId: string | number | null | undefined) {
115
+ const mid = messageId != null && messageId !== '' ? String(messageId).trim() : ''
116
+ if (!sk?.trim() || !mid) return
117
+ let q = cronMessageIdMap.get(sk)
118
+ if (!q) {
119
+ q = []
120
+ cronMessageIdMap.set(sk, q)
121
+ }
122
+ q.push(mid)
115
123
  }
116
124
 
125
+ /** 窥视队首 messageId(不移除),供发送中与 finished 配对使用 */
117
126
  export function getCronMessageId(sk: string): string {
118
- return cronMessageIdMap.get(sk) ?? ''
127
+ if (!sk?.trim()) return ''
128
+ return cronMessageIdMap.get(sk)?.[0] ?? ''
119
129
  }
120
130
 
131
+ /** 弹出队首一条,与一次 finished 对应;队列为空时移除 key */
121
132
  export function removeCronMessageId(sk: string) {
122
- cronMessageIdMap.delete(sk)
133
+ if (!sk?.trim()) return
134
+ const q = cronMessageIdMap.get(sk)
135
+ if (!q?.length) return
136
+ q.shift()
137
+ if (q.length === 0) {
138
+ cronMessageIdMap.delete(sk)
139
+ }
123
140
  }
124
141
 
125
142
  export const getSessionKey = (content: any, accountId: string) => {