@dcrays/dcgchat-test 0.5.0-alpha.2 → 0.5.0-alpha.3

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.
Files changed (42) hide show
  1. package/index.js +292 -0
  2. package/package.json +7 -15
  3. package/schemas/gateway-cron-finished.payload.json +39 -0
  4. package/index.ts +0 -24
  5. package/src/agent.ts +0 -128
  6. package/src/bot.ts +0 -515
  7. package/src/channel.ts +0 -474
  8. package/src/cron.ts +0 -199
  9. package/src/cronToolCall.ts +0 -202
  10. package/src/gateway/cronFinishedPayload.ts +0 -118
  11. package/src/gateway/index.ts +0 -452
  12. package/src/gateway/security.ts +0 -95
  13. package/src/gateway/socket.ts +0 -285
  14. package/src/libs/ali-oss-6.23.0.tgz +0 -0
  15. package/src/libs/axios-1.13.6.tgz +0 -0
  16. package/src/libs/md5-2.3.0.tgz +0 -0
  17. package/src/libs/mime-types-3.0.2.tgz +0 -0
  18. package/src/libs/unzipper-0.12.3.tgz +0 -0
  19. package/src/libs/ws-8.19.0.tgz +0 -0
  20. package/src/monitor.ts +0 -165
  21. package/src/request/api.ts +0 -70
  22. package/src/request/oss.ts +0 -212
  23. package/src/request/request.ts +0 -192
  24. package/src/request/userInfo.ts +0 -93
  25. package/src/session.ts +0 -19
  26. package/src/sessionTermination.ts +0 -168
  27. package/src/skill.ts +0 -146
  28. package/src/tool.ts +0 -403
  29. package/src/tools/messageTool.ts +0 -273
  30. package/src/transport.ts +0 -206
  31. package/src/types.ts +0 -139
  32. package/src/utils/agentErrors.ts +0 -23
  33. package/src/utils/constant.ts +0 -7
  34. package/src/utils/gatewayMsgHanlder.ts +0 -84
  35. package/src/utils/global.ts +0 -161
  36. package/src/utils/log.ts +0 -15
  37. package/src/utils/params.ts +0 -88
  38. package/src/utils/searchFile.ts +0 -228
  39. package/src/utils/workspaceFilePaths.ts +0 -89
  40. package/src/utils/wsMessageHandler.ts +0 -64
  41. package/src/utils/zipExtract.ts +0 -97
  42. package/src/utils/zipPath.ts +0 -24
package/src/bot.ts DELETED
@@ -1,515 +0,0 @@
1
- import path from 'node:path'
2
- import type { ReplyPayload } from 'openclaw/plugin-sdk'
3
- import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline'
4
- import type { InboundMessage } from './types.js'
5
- import { getSessionKey, getWorkspaceDir, setMsgStatus } from './utils/global.js'
6
- import { clearSentMediaKeys, getDcgchatRuntime, getOpenClawConfig } from './utils/global.js'
7
- import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
8
- import { generateSignUrl } from './request/api.js'
9
- import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
10
- import { dcgLogger } from './utils/log.js'
11
- import { contextOverflowUserHint, isContextOverflowError } from './utils/agentErrors.js'
12
- import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
13
- import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
14
- import { waitUntilSubagentsIdle } from './tool.js'
15
- import { beginSupersedingUserTurn, clearActiveRunIdForSession, clearSessionStreamSuppression } from './sessionTermination.js'
16
- import { interruptLocalDispatchAndGateway, isSessionStreamSuppressed, preemptInboundQueueForStop } from './sessionTermination.js'
17
- import { releaseDispatchAbortIfCurrent, runInboundTurnSequenced, setActiveRunIdForSession } from './sessionTermination.js'
18
-
19
- type MediaInfo = {
20
- path: string
21
- fileName: string
22
- contentType: string
23
- placeholder: string
24
- }
25
-
26
- type TFileInfo = { name: string; url: string }
27
-
28
- const mediaMaxBytes = 300 * 1024 * 1024
29
-
30
- /**
31
- * 每条入站消息(含 /stop)递增。用于丢弃「已被后续入站抢占」的处理在 dispatch 结束后的尾部逻辑。
32
- * 仅 /stop 时的 epoch 无法覆盖「新用户消息抢占上一轮」的并发 WS 场景,会导致旧 handler 仍执行 sendFinal(end)、
33
- * 而新消息被误判为 stale;且上一轮若未对网关 chat.abort,服务端 run 会继续,表现为「新消息秒结束、旧回复复活」。
34
- */
35
- const inboundGenerationBySessionKey = new Map<string, number>()
36
-
37
- function bumpInboundGeneration(sessionKey: string): number {
38
- const n = (inboundGenerationBySessionKey.get(sessionKey) ?? 0) + 1
39
- inboundGenerationBySessionKey.set(sessionKey, n)
40
- return n
41
- }
42
-
43
- /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
44
- const streamChunkIdxBySessionKey = new Map<string, number>()
45
-
46
- export function extractAgentIdFromConversationId(conversationId: string): string | null {
47
- const idx = conversationId.indexOf('::')
48
- if (idx <= 0) return null
49
- return conversationId.slice(0, idx)
50
- }
51
- function formatText(text: string): string {
52
- if (!text) return ''
53
- const str = String(text).replace(/\s/g, '')
54
- if (!str) return ''
55
- if (str.length <= 50) {
56
- return str
57
- }
58
- return str.slice(0, 25) + `...[此处省略${str.length - 50}字]....` + str.slice(-25)
59
- }
60
- async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promise<MediaInfo[]> {
61
- const core = getDcgchatRuntime()
62
- const out: MediaInfo[] = []
63
- dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
64
- for (let i = 0; i < files.length; i++) {
65
- const file = files[i]
66
- try {
67
- let data = ''
68
- if (/^https?:\/\//i.test(file.url)) {
69
- data = file.url
70
- } else {
71
- data = await generateSignUrl(file.url, botToken)
72
- }
73
- dcgLogger(`media: generateSignUrl: ${data}`)
74
- const response = await fetch(data)
75
- if (!response.ok) {
76
- dcgLogger?.(`media: ${file.url} fetch failed with HTTP ${response.status}`, 'error')
77
- continue
78
- }
79
- const buffer = Buffer.from(await response.arrayBuffer())
80
-
81
- let contentType = response.headers.get('content-type') || ''
82
- if (!contentType) {
83
- contentType = (await core.media.detectMime({ buffer })) || ''
84
- }
85
- const fileName = file.name || path.basename(new URL(file.url).pathname) || 'file'
86
- const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, 'inbound', mediaMaxBytes, fileName)
87
- const isImage = contentType.startsWith('image/')
88
- out.push({
89
- path: saved.path,
90
- fileName,
91
- contentType: saved.contentType || '',
92
- placeholder: isImage ? '<media:image>' : '<media:file>'
93
- })
94
- } catch (err) {
95
- dcgLogger(`media: ${file.url} FAILED to process: ${String(err)}`, 'error')
96
- }
97
- }
98
- dcgLogger(`media: resolve complete, ${out.length}/${files.length} file(s) succeeded`)
99
- return out
100
- }
101
-
102
- type MediaPayload = {
103
- MediaPath?: string
104
- MediaFileName?: string
105
- MediaType?: string
106
- MediaUrl?: string
107
- MediaFileNames?: string[]
108
- MediaPaths?: string[]
109
- MediaUrls?: string[]
110
- MediaTypes?: string[]
111
- }
112
- function buildMediaPayload(mediaList: MediaInfo[]): MediaPayload {
113
- if (mediaList.length === 0) return {}
114
- const first = mediaList[0]
115
- const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean)
116
- const mediaPaths = mediaList.map((m) => m.path)
117
- const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean)
118
- return {
119
- MediaPath: first?.path,
120
- MediaFileName: first?.fileName,
121
- MediaType: first?.contentType,
122
- MediaUrl: first?.path,
123
- MediaFileNames: mediaFileNames.length > 0 ? mediaFileNames : undefined,
124
- MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
125
- MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
126
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined
127
- }
128
- }
129
-
130
- function resolveReplyMediaList(payload: ReplyPayload): string[] {
131
- const p = payload as { mediaUrls?: unknown[]; mediaUrl?: unknown }
132
- if (p.mediaUrls != null && Array.isArray(p.mediaUrls) && p.mediaUrls.length > 0) {
133
- return normalizeOutboundMediaPaths(p.mediaUrls)
134
- }
135
- return normalizeOutboundMediaPaths(p.mediaUrl ?? null)
136
- }
137
-
138
- const typingCallbacks = createTypingCallbacks({
139
- start: async () => {},
140
- onStartError: (err) => {
141
- dcgLogger(`typing start error: ${String(err)}`, 'error')
142
- }
143
- })
144
-
145
- /**
146
- * 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
147
- */
148
- export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
149
- const config = getOpenClawConfig()
150
- if (!config) {
151
- dcgLogger('no config available', 'error')
152
- return
153
- }
154
- const account = resolveAccount(config, accountId)
155
- const queueSessionKey = getSessionKey(msg.content, account.accountId)
156
- const queueText = (msg.content.text ?? '').trim()
157
- if (stopCommand.includes(queueText)) {
158
- preemptInboundQueueForStop(queueSessionKey)
159
- }
160
- await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
161
- }
162
-
163
- async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
164
- const config = getOpenClawConfig()
165
- if (!config) {
166
- return
167
- }
168
- const account = resolveAccount(config, accountId)
169
- const userId = msg._userId.toString()
170
-
171
- const core = getDcgchatRuntime()
172
-
173
- const conversationId = msg.content.session_id?.trim()
174
- const real_mobook = msg.content.real_mobook?.toString().trim()
175
-
176
- const route = core.channel.routing.resolveAgentRoute({
177
- cfg: config,
178
- channel: "dcgchat-test",
179
- accountId: account.accountId,
180
- peer: { kind: 'direct', id: conversationId }
181
- })
182
-
183
- const embeddedAgentId = extractAgentIdFromConversationId(conversationId)
184
-
185
- const effectiveAgentId = embeddedAgentId ?? route.agentId
186
- const dcgSessionKey = getSessionKey(msg.content, account.accountId)
187
- const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
188
-
189
- const mergedParams = {
190
- userId: msg._userId,
191
- botToken: msg.content.bot_token,
192
- sessionId: conversationId,
193
- messageId: msg.content.message_id,
194
- domainId: msg.content.domain_id,
195
- appId: config.channels?.["dcgchat-test"]?.appId || 100,
196
- botId: msg.content.bot_id ?? '',
197
- agentId: msg.content.agent_id ?? '',
198
- sessionKey: dcgSessionKey,
199
- real_mobook
200
- }
201
- /** 写入本条消息参数前快照:流式/abort 的 final 须对齐「上一轮」触发的对话 messageId,而非打断指令本身 */
202
- const priorOutboundCtx = getEffectiveMsgParams(dcgSessionKey)
203
- setParamsMessage(dcgSessionKey, mergedParams)
204
- dcgLogger(`target alias bound: aliasTarget=${userId} -> sessionKey=${dcgSessionKey}`)
205
- const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
206
- const agentEntry =
207
- effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
208
- const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
209
-
210
- let text = msg.content.text?.trim()
211
-
212
- if (!text) {
213
- sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
214
- sendFinal(outboundCtx, 'not text')
215
- setMsgStatus(dcgSessionKey, 'finished')
216
- return
217
- }
218
-
219
- try {
220
- /** 为 true 表示 createReplyDispatcherWithTyping 的 onError 已执行(含 sendFinal),内部 catch 勿再收尾 */
221
- let dispatchReplyErrorHandledByOnError = false
222
- if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
223
- const workspaceDir = getWorkspaceDir()
224
- const skill = msg.content.skills_scope[0]
225
- const skillDir = `${workspaceDir}/skills/${skill.skill_code}`
226
- const skillText = `用户选择使用此技能:"${skill.skill_code}",技能路径是:"${skillDir}",在目录下查找并`
227
- text = skill.skill_code ? `${skillText} ${text}` : text
228
- dcgLogger(`skill: text: ${text}`)
229
- }
230
- // 处理用户上传的文件
231
- const files = msg.content.files ?? []
232
- let mediaPayload: Record<string, unknown> = {}
233
- if (files.length > 0) {
234
- const mediaList = await resolveMediaFromUrls(files, msg.content.bot_token)
235
- mediaPayload = buildMediaPayload(mediaList)
236
- }
237
-
238
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config)
239
- const messageBody = text
240
- const bodyFormatted = core.channel.reply.formatAgentEnvelope({
241
- channel: '书灵墨宝',
242
- from: userId,
243
- timestamp: new Date(),
244
- envelope: envelopeOptions,
245
- body: messageBody
246
- })
247
-
248
- const ctxPayload = core.channel.reply.finalizeInboundContext({
249
- Body: bodyFormatted,
250
- RawBody: text,
251
- CommandBody: text,
252
- From: userId,
253
- To: dcgSessionKey,
254
- SessionKey: dcgSessionKey,
255
- AccountId: route.accountId,
256
- ChatType: 'direct',
257
- SenderName: agentDisplayName,
258
- SenderId: userId,
259
- Provider: "dcgchat-test",
260
- Surface: "dcgchat-test",
261
- MessageSid: msg.content.message_id,
262
- Timestamp: Date.now(),
263
- WasMentioned: true,
264
- CommandAuthorized: true,
265
- OriginatingChannel: "dcgchat-test",
266
- OriginatingTo: dcgSessionKey,
267
- Target: dcgSessionKey,
268
- SourceTarget: dcgSessionKey,
269
- ...mediaPayload
270
- })
271
-
272
- const sentMediaKeys = new Set<string>()
273
- const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
274
- /** 与 Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
275
- let lastStreamSnapshot = ''
276
-
277
- /** Core 在 block/final(及可选 tool)路径走 `deliver`,流式 token 才走 `onPartialReply`;二者需共用快照,避免双发或漏发 */
278
- const emitAssistantTextChunkFromSnapshot = (raw: string | undefined) => {
279
- if (!raw) return
280
- const t = raw
281
- let delta = ''
282
- if (t.startsWith(lastStreamSnapshot)) {
283
- delta = t.slice(lastStreamSnapshot.length)
284
- lastStreamSnapshot = t
285
- } else if (lastStreamSnapshot.startsWith(t)) {
286
- // 快照缩短(模型修订等):不重复下发
287
- } else {
288
- delta = t
289
- lastStreamSnapshot = t
290
- }
291
- if (delta.trim()) {
292
- const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
293
- streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
294
- sendChunk(delta, outboundCtx, prev)
295
- }
296
- }
297
-
298
- if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
299
- const workspaceDir = getWorkspaceDir()
300
- const skill = msg.content.skills_scope[0]
301
- const skillDir = `${workspaceDir}/skills/${skill.skill_code}`
302
- const skillText = `用户选择使用此技能:"${skill.skill_code}",技能路径是:"${skillDir}",在目录下查找并`
303
- text = skill.skill_code ? `${skillText} ${text}` : text
304
- dcgLogger(`skill: text: ${text}`)
305
- }
306
- const prefixContext = createReplyPrefixContext({
307
- cfg: config,
308
- agentId: effectiveAgentId ?? '',
309
- channel: "dcgchat-test",
310
- accountId: account.accountId
311
- })
312
-
313
- const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
314
- responsePrefix: prefixContext.responsePrefix,
315
- responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
316
- humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
317
- onReplyStart: async () => {},
318
- deliver: async (payload: ReplyPayload, info) => {
319
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) return
320
- if (isSessionStreamSuppressed(dcgSessionKey)) return
321
- const mediaList = resolveReplyMediaList(payload)
322
- for (const mediaUrl of mediaList) {
323
- const key = getMediaKey(mediaUrl)
324
- if (sentMediaKeys.has(key)) continue
325
- sentMediaKeys.add(key)
326
- await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
327
- }
328
- if (payload?.text?.trim()) {
329
- emitAssistantTextChunkFromSnapshot(payload.text)
330
- }
331
- dcgLogger(
332
- `[deliver]: kind=${info.kind} len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`
333
- )
334
- },
335
- onError: (err: unknown, info: { kind: string }) => {
336
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
337
- dcgLogger(`${info.kind} reply failed (stale handler, ignored): ${String(err)}`, 'error')
338
- return
339
- }
340
- dispatchReplyErrorHandledByOnError = true
341
- setMsgStatus(dcgSessionKey, 'finished')
342
- const suppressed = isSessionStreamSuppressed(dcgSessionKey)
343
- if (!suppressed && isContextOverflowError(err)) {
344
- sendText(contextOverflowUserHint(), outboundCtx)
345
- }
346
- sendFinal(outboundCtx, 'error')
347
- clearActiveRunIdForSession(dcgSessionKey)
348
- streamChunkIdxBySessionKey.delete(dcgSessionKey)
349
- dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
350
- },
351
- onIdle: () => {
352
- typingCallbacks.onIdle?.()
353
- }
354
- })
355
-
356
- let dispatchAbort: AbortController | undefined
357
- try {
358
- if (!stopCommand.includes(text?.trim())) {
359
- streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
360
- dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
361
- }
362
-
363
- // if (systemCommand.includes(text?.trim())) {
364
- // dcgLogger(`dispatching ${text?.trim()}`)
365
- // await core.channel.reply.withReplyDispatcher({
366
- // dispatcher,
367
- // onSettled: () => markDispatchIdle(),
368
- // run: () =>
369
- // core.channel.reply.dispatchReplyFromConfig({
370
- // ctx: ctxPayload,
371
- // cfg: config,
372
- // dispatcher,
373
- // replyOptions: {
374
- // ...replyOptions,
375
- // abortSignal: dispatchAbort!.signal,
376
- // onModelSelected: prefixContext.onModelSelected,
377
- // onAgentRunStart: (runId) => {
378
- // setActiveRunIdForSession(dcgSessionKey, runId)
379
- // }
380
- // }
381
- // })
382
- // })
383
- // } else
384
- if (stopCommand.includes(text?.trim())) {
385
- const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
386
- await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
387
- streamChunkIdxBySessionKey.delete(dcgSessionKey)
388
- clearSentMediaKeys(msg.content.message_id)
389
- setMsgStatus(dcgSessionKey, 'finished')
390
- clearParamsMessage(dcgSessionKey)
391
- sendText('会话已终止', outboundCtx)
392
- sendFinal(outboundCtx, 'stop')
393
- return
394
- } else {
395
- const params = getEffectiveMsgParams(dcgSessionKey)
396
- if (!ignoreToolCommand.includes(text?.trim())) {
397
- wsSendRaw(params, {
398
- is_finish: -1,
399
- tool_call_id: Date.now().toString(),
400
- is_cover: 0,
401
- thinking_content: JSON.stringify({
402
- type: 'message_received',
403
- specialIdentification: 'dcgchat_tool_call_special_identification',
404
- toolName: '',
405
- callId: Date.now().toString(),
406
- params: ''
407
- }),
408
- response: ''
409
- })
410
- }
411
- await core.channel.reply.withReplyDispatcher({
412
- dispatcher,
413
- onSettled: () => markDispatchIdle(),
414
- run: () =>
415
- core.channel.reply.dispatchReplyFromConfig({
416
- ctx: ctxPayload,
417
- cfg: config,
418
- dispatcher,
419
- replyOptions: {
420
- ...replyOptions,
421
- abortSignal: dispatchAbort!.signal,
422
- onModelSelected: prefixContext.onModelSelected,
423
- onAgentRunStart: (runId) => {
424
- setActiveRunIdForSession(dcgSessionKey, runId)
425
- },
426
- onPartialReply: async (payload: ReplyPayload) => {
427
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) return
428
- if (isSessionStreamSuppressed(dcgSessionKey)) return
429
- // --- Streaming text chunks ---
430
- if (payload.text) {
431
- emitAssistantTextChunkFromSnapshot(payload.text)
432
- } else {
433
- dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
434
- }
435
- // --- Media from payload ---
436
- const mediaList = resolveReplyMediaList(payload)
437
- for (const mediaUrl of mediaList) {
438
- const key = getMediaKey(mediaUrl)
439
- if (sentMediaKeys.has(key)) continue
440
- sentMediaKeys.add(key)
441
- await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
442
- }
443
- }
444
- }
445
- })
446
- })
447
- }
448
- } catch (err: unknown) {
449
- dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
450
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
451
- setMsgStatus(dcgSessionKey, 'finished')
452
- if (!dispatchReplyErrorHandledByOnError && isContextOverflowError(err) && !isSessionStreamSuppressed(dcgSessionKey)) {
453
- sendText(contextOverflowUserHint(), outboundCtx)
454
- sendFinal(outboundCtx, 'error')
455
- clearActiveRunIdForSession(dcgSessionKey)
456
- streamChunkIdxBySessionKey.delete(dcgSessionKey)
457
- return
458
- }
459
- }
460
- } finally {
461
- releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
462
- }
463
-
464
- const inboundGenNow = inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0
465
- if (inboundGenNow !== inboundGenAtStart) {
466
- dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
467
- return
468
- }
469
-
470
- if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
471
- if (isSessionStreamSuppressed(dcgSessionKey)) {
472
- clearSessionStreamSuppression(dcgSessionKey)
473
- }
474
- }
475
- clearSentMediaKeys(msg.content.message_id)
476
- const storePath = core.channel.session.resolveStorePath(config.session?.store)
477
- await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
478
- // 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
479
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
480
- dcgLogger(
481
- `skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${inboundGenerationBySessionKey.get(dcgSessionKey)})`
482
- )
483
- return
484
- }
485
- clearActiveRunIdForSession(dcgSessionKey)
486
- setMsgStatus(dcgSessionKey, 'finished')
487
- sendFinal(outboundCtx, 'end')
488
- dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
489
- core.channel.session
490
- .recordInboundSession({
491
- storePath,
492
- sessionKey: dcgSessionKey,
493
- ctx: ctxPayload,
494
- updateLastRoute: {
495
- sessionKey: dcgSessionKey,
496
- channel: "dcgchat-test",
497
- to: dcgSessionKey,
498
- accountId: route.accountId
499
- },
500
- onRecordError: (err) => {
501
- dcgLogger(` session record error: ${String(err)}`, 'error')
502
- }
503
- })
504
- .catch((err: unknown) => {
505
- dcgLogger(` recordInboundSession failed: ${String(err)}`, 'error')
506
- })
507
- } catch (err) {
508
- dcgLogger(` handle message failed: ${String(err)}`, 'error')
509
- if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
510
- setMsgStatus(dcgSessionKey, 'finished')
511
- }
512
- const rawErr = err instanceof Error ? err.message : String(err)
513
- sendError(isContextOverflowError(err) ? contextOverflowUserHint() : rawErr, outboundCtx)
514
- }
515
- }