@jerryliang122/openclaw-qqbot 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -860,8 +860,13 @@ declare class StreamingController {
860
860
  private session;
861
861
  /** QQ 已接受的最新文本 — 单源真理 */
862
862
  private lastAcceptedFull;
863
- /** 已成功发送的分片数(降级:=0 则走静态消息兜底) */
863
+ /** 已成功发送的分片数(降级:=0 且无外部投递则走静态消息兜底) */
864
864
  private sentChunkCount;
865
+ /**
866
+ * 标记:内容已通过非流式通道成功投递(如 ask_user 按钮卡片)。
867
+ * 置 true 后 handleFinalize 不再走 fallback 路径,避免框架误报 outcome=error。
868
+ */
869
+ private _deliveredExternally;
865
870
  /** 同步标志:收到第一个 onPartialReply 即置 true(不等 async 完成) */
866
871
  private _hasStarted;
867
872
  /** 串行队列 */
@@ -882,6 +887,11 @@ declare class StreamingController {
882
887
  get hasStarted(): boolean;
883
888
  get isTerminal(): boolean;
884
889
  get shouldFallbackToStatic(): boolean;
890
+ /**
891
+ * 告知控制器内容已通过外部通道投递(如 ask_user 卡片)。
892
+ * 后续 finalize() 不会走 fallback 路径,框架不会误报 outcome=error。
893
+ */
894
+ markDeliveredExternally(): void;
885
895
  onPartialReply(text: string): Promise<void>;
886
896
  finalize(): Promise<void>;
887
897
  /**
@@ -950,6 +960,11 @@ declare function resolveQQBotEnvClientSecret(): string;
950
960
  declare function listQQBotAccountIds(cfg: OpenClawConfig): string[];
951
961
  /**
952
962
  * 获取默认账户 ID
963
+ *
964
+ * 优先返回「可运行」的账号:
965
+ * 1. 顶层 default 账号可运行(appId + 凭证来源 + enabled)→ default
966
+ * 2. 否则第一个可运行的命名账号(accounts.<id>)
967
+ * 3. 都不可运行 → 保持旧行为返回 default(错误信息指向配置根因)
953
968
  */
954
969
  declare function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string;
955
970
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jerryliang122/openclaw-qqbot",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "QQ Bot channel plugin for OpenClaw (independently maintained fork)",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedQQBotAccount, QQBotAccountConfig, ToolPolicy, GroupConfig, GroupCoalesceConfig } from "./types.js";
2
2
  import type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk";
3
+ import { loadCredentialBackup } from "./features/credential-backup.js";
3
4
 
4
5
  // ============ Agent-aware mentionPatterns 解析 ============
5
6
 
@@ -248,22 +249,81 @@ export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
248
249
  return Array.from(ids);
249
250
  }
250
251
 
252
+ /**
253
+ * 判断一个账号形状是否「可运行」:配置内声明了 appId、未被禁用,且任一
254
+ * 凭证来源可用(配置内 secret / secretFile / 环境变量 / 凭证暂存备份)。
255
+ *
256
+ * appId 必须来自配置——listQQBotAccountIds 只枚举配置里有 appId 的账号,
257
+ * 框架据此决定启动哪些账号;备份只能补 secret(appId 在、secret 丢的
258
+ * 恢复场景,lifecycle 启动时会用备份恢复),不能让一个未被枚举、根本
259
+ * 不会被启动的账号被选为默认(Sourcery 复审:选中≠可运行)。
260
+ *
261
+ * 用于默认账号解析——只看 appId 是否存在会把已登出(secret 被删、appId 残留)
262
+ * 或停用的顶层账号当成默认账号,导致 message 工具等框架侧主动发送解析到
263
+ * 一个永远不会启动的账号(issue #15:Bot "default" not running)。
264
+ */
265
+ function accountShapeRunnable(params: {
266
+ appId: string;
267
+ enabled: boolean;
268
+ hasSecretSource: boolean;
269
+ accountId: string;
270
+ }): boolean {
271
+ if (!params.appId || !params.enabled) return false;
272
+ if (params.hasSecretSource) return true;
273
+ // 配置缺 secret 但备份可恢复(appId 已在配置内,账号会被枚举启动)
274
+ try {
275
+ return loadCredentialBackup(params.accountId) !== null;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
251
281
  /**
252
282
  * 获取默认账户 ID
283
+ *
284
+ * 优先返回「可运行」的账号:
285
+ * 1. 顶层 default 账号可运行(appId + 凭证来源 + enabled)→ default
286
+ * 2. 否则第一个可运行的命名账号(accounts.<id>)
287
+ * 3. 都不可运行 → 保持旧行为返回 default(错误信息指向配置根因)
253
288
  */
254
289
  export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
255
290
  const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
256
- // 如果有默认账户配置,返回 default
291
+
292
+ // 1. 如果默认账户可运行,返回 default
257
293
  if (qqbot?.appId || resolveQQBotEnvAppId()) {
258
- return DEFAULT_ACCOUNT_ID;
294
+ const runnable = accountShapeRunnable({
295
+ appId: normalizeAppId(qqbot?.appId) || normalizeAppId(resolveQQBotEnvAppId()),
296
+ enabled: qqbot?.enabled !== false,
297
+ hasSecretSource: Boolean(
298
+ qqbot?.clientSecret
299
+ || qqbot?.clientSecretFile
300
+ || resolveQQBotEnvClientSecret(),
301
+ ),
302
+ accountId: DEFAULT_ACCOUNT_ID,
303
+ });
304
+ if (runnable) return DEFAULT_ACCOUNT_ID;
259
305
  }
260
- // 否则返回第一个配置的账户
306
+
307
+ // 2. 否则返回第一个可运行的命名账户
261
308
  if (qqbot?.accounts) {
309
+ for (const [accountId, account] of Object.entries(qqbot.accounts)) {
310
+ if (!account) continue;
311
+ const runnable = accountShapeRunnable({
312
+ appId: normalizeAppId(account.appId),
313
+ enabled: account.enabled !== false,
314
+ hasSecretSource: Boolean(account.clientSecret || account.clientSecretFile),
315
+ accountId,
316
+ });
317
+ if (runnable) return accountId;
318
+ }
319
+ // 兼容旧形态:命名账号均无 appId(凭 backup 恢复)时保持原顺序返回第一个
262
320
  const ids = Object.keys(qqbot.accounts);
263
- if (ids.length > 0) {
321
+ if (ids.length > 0 && ids.every((id) => !qqbot.accounts?.[id]?.appId)) {
264
322
  return ids[0];
265
323
  }
266
324
  }
325
+
326
+ // 3. 旧回退
267
327
  return DEFAULT_ACCOUNT_ID;
268
328
  }
269
329
 
@@ -298,6 +298,8 @@ export async function dispatchToOpenClaw(
298
298
  try {
299
299
  await bot.sendTextWithKeyboard(replyTarget, text, keyboard as never);
300
300
  outboundSendOk++;
301
+ // 标记流式控制器:内容已通过外部通道投递,finalize 不走 fallback
302
+ streamingController?.markDeliveredExternally();
301
303
  dlog?.debug(`[question] sent ask_user with keyboard questionId=${questionId} options=${optionValues.length}`);
302
304
  return;
303
305
  } catch (err) {
@@ -331,6 +333,8 @@ export async function dispatchToOpenClaw(
331
333
  await bot.sendTextWithKeyboard(replyTarget, cardText, keyboard as never);
332
334
  outboundSendOk++;
333
335
  }
336
+ // 标记流式控制器:内容已通过外部通道投递,finalize 不走 fallback
337
+ streamingController?.markDeliveredExternally();
334
338
  dlog?.debug(`[question] sent multi-question ask_user questionId=${questionId} questions=${questions.length}`);
335
339
  return;
336
340
  } catch (err) {
@@ -184,7 +184,9 @@ export async function stopAccountGracefully(params: {
184
184
  }
185
185
  }
186
186
 
187
- unregisterGateway(accountId);
187
+ // 3. 注销 gateway(带所有权守卫:只删本实例注册的同一个 gw,
188
+ // 防止延迟 stop 误删新模块实例已注册的替代网关)
189
+ unregisterGateway(accountId, gw);
188
190
  }
189
191
 
190
192
  /**
@@ -20,7 +20,7 @@ import { tryGetQQBotRuntime } from '../runtime.js';
20
20
  import { getAdapters } from '../adapter/resolve.js';
21
21
  import type { PluginLogger } from '../utils/plugin-logger.js';
22
22
  import { validateRemoteUrl } from '../utils/ssrf-guard.js';
23
- import { getGateway } from './outbound-service.js';
23
+ import { resolveGatewayForSend, notRunningError } from './outbound-service.js';
24
24
  import { parseTarget } from './target.js';
25
25
  import { checkAndConsumePassiveReplyQuota } from '../features/quota-manager.js';
26
26
  import {
@@ -158,18 +158,21 @@ export async function sendMedia(params: SendMediaParams): Promise<SendMediaResul
158
158
  ?? (params.mimeType ? inferMediaKindFromMime(params.mimeType) : undefined)
159
159
  ?? inferMediaKind(resolved.path);
160
160
 
161
- // 3. 获取 gateway
162
- const gw = getGateway(accountId);
163
- if (!gw) {
164
- return { error: `Bot "${accountId}" not running` };
161
+ // 3. 获取 gateway(带单账号回退:issue #15——message 工具主动发媒体时
162
+ // 请求键可能解析到未运行的 "default",单账号部署回退到唯一运行中的网关)。
163
+ // 配额记账用 resolved.accountId(实际发送账号),见 resolveGatewayForSend 注释。
164
+ const sendGateway = resolveGatewayForSend(accountId);
165
+ if (!sendGateway) {
166
+ return { error: notRunningError(accountId) };
165
167
  }
168
+ const gw = sendGateway.gw;
166
169
 
167
170
  const target = parseTarget(params.to);
168
171
 
169
172
  const reservation = params.quotaReserved || !params.replyToId
170
173
  ? { canReply: Boolean(params.replyToId), rollback: () => {} }
171
174
  : checkAndConsumePassiveReplyQuota({
172
- accountId,
175
+ accountId: sendGateway.accountId,
173
176
  msgId: params.replyToId,
174
177
  scope: target.scope,
175
178
  log,
@@ -8,6 +8,7 @@ import * as path from 'node:path';
8
8
  import { MediaFileType } from '@tencent-connect/qqbot-nodejs';
9
9
  import type { QQBotGateway } from '../gateway/index.js';
10
10
  import type { ResolvedQQBotAccount } from '../types.js';
11
+ import { createPluginLogger } from '../utils/plugin-logger.js';
11
12
  import { parseTarget } from './target.js';
12
13
  import {
13
14
  checkAndConsumePassiveReplyQuota,
@@ -17,7 +18,41 @@ import {
17
18
 
18
19
  // ── Gateway 注册表(生命周期由 channel.ts 管理)──
19
20
 
20
- const gateways = new Map<string, QQBotGateway>();
21
+ /**
22
+ * 进程级注册表桥(issue #15):
23
+ *
24
+ * openclaw 的 message 工具在部分执行上下文(后台任务 / bootstrap 兜底注册表)
25
+ * 会经 jiti/二次加载产生插件的**另一个模块实例**——模块级 Map 在两个实例里
26
+ * 各持一份,第二实例的注册表为空,getGateway 恒 miss,主动发送全部报
27
+ * `Bot "<accountId>" not running`(账号 id 本身解析是对的)。gateway 是
28
+ * 进程级资源(WebSocket 连接),注册表挂到 globalThis(Symbol.for 跨副本
29
+ * 共享)让所有模块实例看到同一份。
30
+ */
31
+ const GATEWAYS_REGISTRY_KEY = Symbol.for('openclaw-qqbot.gateways');
32
+
33
+ const globalRegistry = globalThis as unknown as Record<symbol, unknown>;
34
+ const gateways: Map<string, QQBotGateway> =
35
+ (globalRegistry[GATEWAYS_REGISTRY_KEY] as Map<string, QQBotGateway> | undefined) ?? new Map();
36
+ globalRegistry[GATEWAYS_REGISTRY_KEY] = gateways;
37
+
38
+ // 契约入口(框架直发 / message 工具 / cron)无调用方 logger 可传,
39
+ // 回退日志用模块级 logger 兜底(与 outbound-adapter.ts 的 alog 同模式)。
40
+ // 注意:本模块处于 bot-instance → outbound-service → plugin-logger → runtime
41
+ // → bot-instance 的循环依赖环上,logger 必须惰性创建(顶层求值会在模块
42
+ // 环半初始化状态下崩溃),且日志失败绝不影响发送。
43
+ let svcLog: ReturnType<typeof createPluginLogger> | undefined;
44
+
45
+ function logFallbackInfo(msg: string): void {
46
+ try {
47
+ svcLog ??= createPluginLogger({ prefix: '[outbound]' });
48
+ svcLog.info(msg);
49
+ } catch {
50
+ /* logging must never break sends */
51
+ }
52
+ }
53
+
54
+ /** 单账号回退的 INFO 日志去重:每个失效账号键只打一次,避免刷屏 */
55
+ const gatewayFallbackLogged = new Set<string>();
21
56
 
22
57
  /**
23
58
  * 为一次实际 API 调用预留被动回复配额。
@@ -66,7 +101,16 @@ export function registerGateway(accountId: string, gw: QQBotGateway): void {
66
101
  gateways.set(accountId, gw);
67
102
  }
68
103
 
69
- export function unregisterGateway(accountId: string): void {
104
+ export function unregisterGateway(accountId: string, expectGw?: QQBotGateway): void {
105
+ if (expectGw) {
106
+ // 所有权守卫(Sourcery 复审):仅当注册表里持有的是**本实例注册的同一个
107
+ // gateway 对象**才注销——旧模块实例延迟执行的 stop 不得删掉新实例已经
108
+ // 注册的替代网关(共享注册表后,两个实例看到同一份 Map)。
109
+ if (gateways.get(accountId) !== expectGw) return;
110
+ gateways.delete(accountId);
111
+ clearQuotaCacheForAccount(accountId);
112
+ return;
113
+ }
70
114
  gateways.delete(accountId);
71
115
  clearQuotaCacheForAccount(accountId);
72
116
  }
@@ -75,6 +119,57 @@ export function getGateway(accountId: string): QQBotGateway | undefined {
75
119
  return gateways.get(accountId);
76
120
  }
77
121
 
122
+ /** 注册表中所有运行中的账号 ID(诊断与单账号回退用) */
123
+ export function getRegisteredAccountIds(): string[] {
124
+ return [...gateways.keys()];
125
+ }
126
+
127
+ /**
128
+ * 发送路径的 gateway 解析(带单账号回退,issue #15):
129
+ * - 精确命中 → 直接返回
130
+ * - 未命中且注册表里恰好只有一个运行中的账号 → 回退到它。单账号部署下
131
+ * 请求键与注册键不一致只可能来自配置残影(如顶层 appId 残留的 "default"),
132
+ * 不存在路由歧义;回退仅打一次 INFO 便于事后取证。
133
+ * - 未命中且运行中账号为 0 或多个 → 返回 undefined 由调用方报错——
134
+ * OpenID 跨账号不通用,多账号下盲目回退会把消息发给错误的 bot。
135
+ *
136
+ * 返回 `accountId` 为**实际发送账号**(回退时 ≠ 请求键):被动回复配额是
137
+ * 平台按「真实发送账号 × msg_id」计的,配额预留/回滚必须用 resolved.accountId
138
+ * 记账,否则会记到从未发送的账号下(绕过或误伤限额)。
139
+ */
140
+ export interface ResolvedSendGateway {
141
+ gw: QQBotGateway;
142
+ /** 实际发送账号 ID(配额记账键) */
143
+ accountId: string;
144
+ /** 回退来源账号键(仅诊断;精确命中时无此字段) */
145
+ fallbackFrom?: string;
146
+ }
147
+
148
+ export function resolveGatewayForSend(accountId: string): ResolvedSendGateway | undefined {
149
+ const gw = gateways.get(accountId);
150
+ if (gw) return { gw, accountId };
151
+ const running = getRegisteredAccountIds();
152
+ if (running.length === 1) {
153
+ const soleAccountId = running[0];
154
+ const soleGw = gateways.get(soleAccountId);
155
+ if (!soleGw) return undefined;
156
+ if (!gatewayFallbackLogged.has(accountId)) {
157
+ gatewayFallbackLogged.add(accountId);
158
+ logFallbackInfo(
159
+ `account "${accountId}" not running; falling back to sole running account "${soleAccountId}"`,
160
+ );
161
+ }
162
+ return { gw: soleGw, accountId: soleAccountId, fallbackFrom: accountId };
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ /** "not running" 错误(多账号时附运行中账号列表,便于排障) */
168
+ export function notRunningError(accountId: string): string {
169
+ const running = getRegisteredAccountIds();
170
+ return `Bot "${accountId}" not running${running.length ? ` (running accounts: ${running.join(', ')})` : ''}`;
171
+ }
172
+
78
173
  // ── 媒体类型映射 ──
79
174
 
80
175
  export type MediaKind = 'image' | 'voice' | 'video' | 'file';
@@ -104,17 +199,17 @@ export async function sendText(params: {
104
199
  quotaReserved?: boolean;
105
200
  }): Promise<SendResult> {
106
201
  const accountId = params.account.accountId;
107
- const gw = gateways.get(accountId);
108
- if (!gw) return { error: `Bot "${accountId}" not running` };
202
+ const resolved = resolveGatewayForSend(accountId);
203
+ if (!resolved) return { error: notRunningError(accountId) };
109
204
  const target = parseTarget(params.to);
110
205
  const reservation = reservePassiveReply({
111
206
  replyToId: params.replyToId,
112
- accountId,
207
+ accountId: resolved.accountId,
113
208
  scope: target.scope,
114
209
  quotaReserved: params.quotaReserved,
115
210
  });
116
211
  try {
117
- const result = await gw.sendText(target, params.text, { msgId: reservation.msgId });
212
+ const result = await resolved.gw.sendText(target, params.text, { msgId: reservation.msgId });
118
213
  return { messageId: result.id };
119
214
  } catch (err: unknown) {
120
215
  reservation.rollback();
@@ -133,12 +228,12 @@ export async function sendMedia(params: {
133
228
  quotaReserved?: boolean;
134
229
  }): Promise<SendResult> {
135
230
  const accountId = params.account.accountId;
136
- const gw = gateways.get(accountId);
137
- if (!gw) return { error: `Bot "${accountId}" not running` };
231
+ const resolved = resolveGatewayForSend(accountId);
232
+ if (!resolved) return { error: notRunningError(accountId) };
138
233
  const target = parseTarget(params.to);
139
234
  const reservation = reservePassiveReply({
140
235
  replyToId: params.replyToId,
141
- accountId,
236
+ accountId: resolved.accountId,
142
237
  scope: target.scope,
143
238
  quotaReserved: params.quotaReserved,
144
239
  });
@@ -147,19 +242,19 @@ export async function sendMedia(params: {
147
242
  const msgId = reservation.msgId;
148
243
  if (kind === 'voice') {
149
244
  const source = resolveVoiceSource(params.mediaUrl);
150
- const result = await gw.sendVoice(target, source, { text: params.text, msgId });
245
+ const result = await resolved.gw.sendVoice(target, source, { text: params.text, msgId });
151
246
  return { messageId: result.id };
152
247
  }
153
248
  if (kind === 'video') {
154
- const result = await gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
249
+ const result = await resolved.gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
155
250
  return { messageId: result.id };
156
251
  }
157
252
  if (kind === 'file') {
158
- const result = await gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
253
+ const result = await resolved.gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
159
254
  return { messageId: result.id };
160
255
  }
161
256
  const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
162
- const result = await gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
257
+ const result = await resolved.gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
163
258
  return { messageId: result.id };
164
259
  } catch (err: unknown) {
165
260
  reservation.rollback();
@@ -176,17 +271,17 @@ export async function sendVoice(params: {
176
271
  quotaReserved?: boolean;
177
272
  }): Promise<SendResult> {
178
273
  const accountId = params.account.accountId;
179
- const gw = gateways.get(accountId);
180
- if (!gw) return { error: `Bot "${accountId}" not running` };
274
+ const resolved = resolveGatewayForSend(accountId);
275
+ if (!resolved) return { error: notRunningError(accountId) };
181
276
  const target = parseTarget(params.to);
182
277
  const reservation = reservePassiveReply({
183
278
  replyToId: params.replyToId,
184
- accountId,
279
+ accountId: resolved.accountId,
185
280
  scope: target.scope,
186
281
  quotaReserved: params.quotaReserved,
187
282
  });
188
283
  try {
189
- const result = await gw.sendVoice(target, params.source, { msgId: reservation.msgId });
284
+ const result = await resolved.gw.sendVoice(target, params.source, { msgId: reservation.msgId });
190
285
  return { messageId: result.id };
191
286
  } catch (err: unknown) {
192
287
  reservation.rollback();
@@ -203,17 +298,17 @@ export async function sendVideo(params: {
203
298
  quotaReserved?: boolean;
204
299
  }): Promise<SendResult> {
205
300
  const accountId = params.account.accountId;
206
- const gw = gateways.get(accountId);
207
- if (!gw) return { error: `Bot "${accountId}" not running` };
301
+ const resolved = resolveGatewayForSend(accountId);
302
+ if (!resolved) return { error: notRunningError(accountId) };
208
303
  const target = parseTarget(params.to);
209
304
  const reservation = reservePassiveReply({
210
305
  replyToId: params.replyToId,
211
- accountId,
306
+ accountId: resolved.accountId,
212
307
  scope: target.scope,
213
308
  quotaReserved: params.quotaReserved,
214
309
  });
215
310
  try {
216
- const result = await gw.sendVideo(target, params.videoUrl, { msgId: reservation.msgId });
311
+ const result = await resolved.gw.sendVideo(target, params.videoUrl, { msgId: reservation.msgId });
217
312
  return { messageId: result.id };
218
313
  } catch (err: unknown) {
219
314
  reservation.rollback();
@@ -51,9 +51,15 @@ export class StreamingController {
51
51
  /** QQ 已接受的最新文本 — 单源真理 */
52
52
  private lastAcceptedFull = '';
53
53
 
54
- /** 已成功发送的分片数(降级:=0 则走静态消息兜底) */
54
+ /** 已成功发送的分片数(降级:=0 且无外部投递则走静态消息兜底) */
55
55
  private sentChunkCount = 0;
56
56
 
57
+ /**
58
+ * 标记:内容已通过非流式通道成功投递(如 ask_user 按钮卡片)。
59
+ * 置 true 后 handleFinalize 不再走 fallback 路径,避免框架误报 outcome=error。
60
+ */
61
+ private _deliveredExternally = false;
62
+
57
63
  /** 同步标志:收到第一个 onPartialReply 即置 true(不等 async 完成) */
58
64
  private _hasStarted = false;
59
65
 
@@ -87,7 +93,15 @@ export class StreamingController {
87
93
  }
88
94
 
89
95
  get shouldFallbackToStatic(): boolean {
90
- return this.isTerminal && this.sentChunkCount === 0;
96
+ return this.isTerminal && this.sentChunkCount === 0 && !this._deliveredExternally;
97
+ }
98
+
99
+ /**
100
+ * 告知控制器内容已通过外部通道投递(如 ask_user 卡片)。
101
+ * 后续 finalize() 不会走 fallback 路径,框架不会误报 outcome=error。
102
+ */
103
+ markDeliveredExternally(): void {
104
+ this._deliveredExternally = true;
91
105
  }
92
106
 
93
107
  // ── 入口 ──
@@ -212,6 +226,9 @@ export class StreamingController {
212
226
  } else if (this.sentChunkCount > 0 && this.lastAcceptedFull) {
213
227
  // 未提供 sendStatic → 降级标记,由上层兜底发送
214
228
  this.transition('failed', 'finalize:no_sendstatic');
229
+ } else if (this._deliveredExternally) {
230
+ // 内容已通过外部通道投递(如 ask_user 卡片),非失败
231
+ this.transition('done', 'finalize');
215
232
  } else {
216
233
  this.transition('done', 'finalize:fallback');
217
234
  }
@@ -227,8 +244,8 @@ export class StreamingController {
227
244
  }
228
245
 
229
246
  // 无会话 — 视有无下发决定终态
230
- if (this.sentChunkCount > 0) {
231
- this.transition('done', 'finalize:no_session');
247
+ if (this.sentChunkCount > 0 || this._deliveredExternally) {
248
+ this.transition('done', this.sentChunkCount > 0 ? 'finalize:no_session' : 'finalize');
232
249
  } else {
233
250
  this.transition('done', 'finalize:fallback');
234
251
  }