@dcrays/dcgchat-test 0.4.19 → 0.4.21

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-test",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
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 { getDescendantSessionKeysForRequester, 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(供观测;/stop 时对子会话与主会话使用不传 runId 的 chat.abort 以终止该键下全部活跃 run) */
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,7 +155,7 @@ 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
161
  const config = getOpenClawConfig()
@@ -149,6 +164,20 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
149
164
  return
150
165
  }
151
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)
152
181
  const userId = msg._userId.toString()
153
182
 
154
183
  const core = getDcgchatRuntime()
@@ -167,6 +196,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
167
196
 
168
197
  const effectiveAgentId = embeddedAgentId ?? route.agentId
169
198
  const dcgSessionKey = getSessionKey(msg.content, account.accountId)
199
+ const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
170
200
 
171
201
  const mergedParams = {
172
202
  userId: msg._userId,
@@ -194,6 +224,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
194
224
  if (!text) {
195
225
  sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
196
226
  sendFinal(outboundCtx, 'not text')
227
+ setMsgStatus(dcgSessionKey, 'finished')
197
228
  return
198
229
  }
199
230
 
@@ -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,40 +347,19 @@ 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
- sessionStreamSuppressed.add(dcgSessionKey)
330
- // 网关侧彻底中止:子 agent 使用独立 sessionKey,仅 abort 主会话不会停子 run;自深到浅 abort 后代,再 abort 主会话;不传 runId 以终止该键下全部活跃 chat run
331
- const descendantKeys = getDescendantSessionKeysForRequester(dcgSessionKey)
332
- const abortSubKeys = [...descendantKeys].reverse()
333
- if (abortSubKeys.length > 0) {
334
- dcgLogger(`interrupt: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
335
- }
336
- for (const subKey of abortSubKeys) {
337
- try {
338
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
339
- } catch (e) {
340
- dcgLogger(`chat.abort subagent ${subKey}: ${String(e)}`, 'error')
341
- }
342
- }
343
- try {
344
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
345
- } catch (e) {
346
- dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
347
- }
348
- activeRunIdBySessionKey.delete(dcgSessionKey)
360
+ await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
349
361
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
350
362
  clearSentMediaKeys(msg.content.message_id)
351
- resetSubagentStateForRequesterSession(dcgSessionKey)
352
363
  setMsgStatus(dcgSessionKey, 'finished')
353
364
  clearParamsMessage(dcgSessionKey)
354
365
  sendText('会话已终止', outboundCtx)
@@ -381,12 +392,13 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
381
392
  dispatcher,
382
393
  replyOptions: {
383
394
  ...replyOptions,
395
+ abortSignal: dispatchAbort!.signal,
384
396
  onModelSelected: prefixContext.onModelSelected,
385
397
  onAgentRunStart: (runId) => {
386
- activeRunIdBySessionKey.set(dcgSessionKey, runId)
398
+ setActiveRunIdForSession(dcgSessionKey, runId)
387
399
  },
388
400
  onPartialReply: async (payload: ReplyPayload) => {
389
- if (sessionStreamSuppressed.has(dcgSessionKey)) return
401
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
390
402
 
391
403
  // --- Streaming text chunks ---
392
404
  if (payload.text) {
@@ -424,17 +436,37 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
424
436
  })
425
437
  }
426
438
  } catch (err: unknown) {
427
- 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)
445
+ }
446
+
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
428
451
  }
429
452
 
430
- if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
431
- if (sessionStreamSuppressed.has(dcgSessionKey)) {
432
- sessionStreamSuppressed.delete(dcgSessionKey)
453
+ if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
454
+ if (isSessionStreamSuppressed(dcgSessionKey)) {
455
+ clearSessionStreamSuppression(dcgSessionKey)
433
456
  }
434
457
  }
435
458
  clearSentMediaKeys(msg.content.message_id)
436
459
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
437
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')
438
470
  sendFinal(outboundCtx, 'end')
439
471
  dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
440
472
  core.channel.session
@@ -457,6 +489,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
457
489
  })
458
490
  } catch (err) {
459
491
  dcgLogger(` handle message failed: ${String(err)}`, 'error')
492
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
493
+ setMsgStatus(dcgSessionKey, 'finished')
494
+ }
460
495
  sendError(err instanceof Error ? err.message : String(err), outboundCtx)
461
496
  }
462
497
  }
package/src/channel.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
14
14
  import { dcgLogger, setLogger } from './utils/log.js'
15
15
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
16
+ import { isSessionActiveForTool } from './tool.js'
16
17
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
17
18
  import { getCronJobsPath, readCronJob } from './cron.js'
18
19
 
@@ -129,9 +130,8 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
129
130
  dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
130
131
  return
131
132
  }
132
-
133
133
  const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
134
-
134
+ const notMessageId = `${msgCtx?.messageId}`?.length !== 13 || !msgCtx?.messageId
135
135
  try {
136
136
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat-test"]?.botToken ?? ''
137
137
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
@@ -140,14 +140,16 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
140
140
  }
141
141
  wsSendRaw(msgCtx, {
142
142
  response: opts.text ?? '',
143
- is_finish: `${msgCtx?.messageId}`?.length === 13 ? -1 : 0,
143
+ is_finish: notMessageId ? -1 : 0,
144
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
144
145
  files: [{ url, name: fileName }]
145
146
  })
146
147
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
147
148
  } catch (error) {
148
149
  wsSendRaw(msgCtx, {
149
150
  response: opts.text ?? '',
150
- is_finish: `${msgCtx?.messageId}`?.length === 13 ? -1 : 0,
151
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
152
+ is_finish: notMessageId ? -1 : 0,
151
153
  files: [{ url: opts.mediaUrl ?? '', name: fileName }]
152
154
  })
153
155
  dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
@@ -295,24 +297,20 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
295
297
  const outboundCtx = getOutboundMsgParams(to)
296
298
  const content: Record<string, unknown> = { response: ctx.text }
297
299
  if (!isCron) {
298
- // messageId = getCronMessageId(to) || `${Date.now()}`
299
- // const { sessionId, agentId } = getInfoBySessionKey(to)
300
- // content.is_finish = -1
301
- // content.message_tags = { source: 'cron' }
302
- // const merged = mergeDefaultParams({
303
- // agentId: agentId,
304
- // sessionId: `${sessionId}`,
305
- // messageId: messageId,
306
- // real_mobook: !sessionId ? 1 : ''
307
- // })
308
- // wsSendRaw(merged, content)
309
- // } else {
310
300
  if (outboundCtx?.sessionId) {
301
+ // 入站 handler 已将本轮标为 running;若已 sendFinal(end) 则应为 finished。
302
+ // 否则网关/Core 晚到的正文会仍用「当前 params map」里的 messageId,错挂到后一条用户消息上。
303
+ if (!isSessionActiveForTool(to)) {
304
+ dcgLogger(`channel sendText dropped (session not active): to=${to}`)
305
+ return {
306
+ channel: "dcgchat-test",
307
+ messageId: '',
308
+ chatId: outboundChatId(ctx.to, to)
309
+ }
310
+ }
311
311
  messageId = outboundCtx?.messageId || `${Date.now()}`
312
312
  const newCtx = { ...outboundCtx, messageId }
313
313
  wsSendRaw(newCtx, content)
314
- } else {
315
- dcgLogger(`channel sendText to ${ctx.to} -> sessionId not found`, 'error')
316
314
  }
317
315
  }
318
316
  }
@@ -331,8 +329,8 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
331
329
  const cronMsgId = getCronMessageId(to)
332
330
  const fallbackMessageId = `${Date.now()}`
333
331
  const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
334
-
335
- if (!outboundCtx?.sessionId) {
332
+ const { sessionId } = getInfoBySessionKey(to)
333
+ if (!sessionId) {
336
334
  dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
337
335
  return {
338
336
  channel: "dcgchat-test",
@@ -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-test"
145
+ apply(newParams.delivery as CronDelivery)
131
146
  newParams.sessionKey = sk
132
147
  return newParams
133
148
  }
134
149
 
135
- // job.delivery
136
150
  const job = newParams.job as Record<string, unknown> | undefined
137
151
  if (job?.delivery && typeof job.delivery === 'object') {
138
- const jd = job.delivery as CronDelivery
139
- jd.bestEffort = true
140
- jd.to = `dcg-cron:${sk}`
141
- jd.accountId = agentId
142
- jd.channel = "dcgchat-test"
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) {
@@ -157,20 +167,24 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
157
167
  dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
158
168
  return undefined
159
169
  }
160
- if (!needsBestEffort(delivery)) {
161
- 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.`
164
- )
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.`)
165
173
  return undefined
166
174
  }
167
175
 
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
- )
176
+ const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
177
+ if (needsBestEffort(delivery)) {
178
+ dcgLogger(
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)}`
186
+ )
187
+ }
174
188
 
175
189
  return { params: newParams }
176
190
  } else if (toolName === 'exec') {
@@ -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
@@ -65,7 +65,7 @@ function resolveOutboundParamsForSession(sk: string) {
65
65
  }
66
66
 
67
67
  /** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
68
- function isSessionActiveForTool(sk: string): boolean {
68
+ export function isSessionActiveForTool(sk: string): boolean {
69
69
  const k = sk.trim()
70
70
  if (!k) return false
71
71
  if (getMsgStatus(k) === 'running') return true
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'test'
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]