lark-remote 0.5.17 → 0.5.19

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 完整回答超出一张卡片时,落成 Markdown 文件随卡片发送。
3
+ *
4
+ * 为什么不再拆纯文本消息:拆出来的「完整回答(1/N)」与卡片正文重复
5
+ * (2026-11-02 实机反馈:同一篇回答用户看到两遍),而且纯文本会丢表格、
6
+ * 代码块等 Markdown 结构。文件通道保留原文逐字不变,且只多一条文件消息。
7
+ *
8
+ * 调用方负责用后删除:`bestEffortUnlink()` 见 `common/fs.ts`。
9
+ */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { cardWithHeader } from './card-shared.js';
15
+ import { markdownDiv } from './collapsible.js';
16
+ /** 文件名里的可读时间戳(本地时区),如 `20261102-0715`。 */
17
+ function fileStamp(now) {
18
+ const pad = (n) => String(n).padStart(2, '0');
19
+ return (`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
20
+ `-${pad(now.getHours())}${pad(now.getMinutes())}`);
21
+ }
22
+ /**
23
+ * 把完整回答写进系统临时目录里的 `.md` 文件,返回绝对路径。
24
+ *
25
+ * 文件名带时间戳与随机后缀:同一分钟内的多个 run 不会互相覆盖,也不会在
26
+ * 飞书里显示成同名附件。内容逐字写入,不做任何截断或转义。
27
+ */
28
+ export function writeFinalAnswerFile(text, now = new Date()) {
29
+ const suffix = randomUUID().slice(0, 8);
30
+ const filePath = path.join(os.tmpdir(), `lark-remote-final-answer-${fileStamp(now)}-${suffix}.md`);
31
+ fs.writeFileSync(filePath, text, 'utf8');
32
+ return filePath;
33
+ }
34
+ /** 文件通道失败时的提示卡——终态反馈也只走卡片,不发纯文本。 */
35
+ export function finalAnswerFileFailureCard(message) {
36
+ return cardWithHeader({
37
+ template: 'red',
38
+ title: '⚠️ 完整回答文件发送失败',
39
+ elements: [markdownDiv(`完整回答没能作为 Markdown 文件发出:${message}`)],
40
+ });
41
+ }
42
+ //# sourceMappingURL=final-answer-file.js.map
@@ -50,6 +50,8 @@ export class RunCardSession extends CardSession {
50
50
  await this.prepareTerminalUpdate();
51
51
  this.state = finishRun(this.state, terminal, meta);
52
52
  await this.updateCard();
53
+ // 终态帧必须真的落地(throttle 通道 resolve 时 patch 还没发)。
54
+ await this.confirmTerminalFrame(`runId=${this.state.runId} terminal=${this.state.terminal}`);
53
55
  this.release();
54
56
  }
55
57
  }
@@ -85,6 +85,6 @@ export declare function renderRunCard(state: RunState, options?: RunCardRenderOp
85
85
  * by streaming sessions.
86
86
  */
87
87
  export declare function renderRunCardDetailed(state: RunState, options?: RunCardRenderOptions): RunCardRenderOutcome;
88
- /** Exact-match marker used in the card before the answer is sent separately. */
89
- export declare const FINAL_ANSWER_OVERFLOW_MARKER = "_\u6700\u7EC8\u56DE\u7B54\u8F83\u957F\uFF0C\u5B8C\u6574\u5185\u5BB9\u5DF2\u968F\u540E\u53D1\u9001_";
88
+ /** Exact-match marker: the body is delivered as an attached Markdown file. */
89
+ export declare const FINAL_ANSWER_OVERFLOW_MARKER = "_\u6700\u7EC8\u56DE\u7B54\u8F83\u957F\uFF0C\u5B8C\u6574\u5185\u5BB9\u89C1\u968F\u9644\u7684 Markdown \u6587\u4EF6_";
90
90
  export {};
@@ -1,4 +1,4 @@
1
- import { isRunActive, isRunTerminal, } from './run-state.js';
1
+ import { isLiveBackgroundJob, isRunActive, isRunTerminal, } from './run-state.js';
2
2
  import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, cardButton, agentDisplayName, } from './card-shared.js';
3
3
  import { collapsibleMarkdownPanel, markdownDiv } from './collapsible.js';
4
4
  import { toolBodyMd, toolHeaderText } from './tool-render.js';
@@ -941,22 +941,27 @@ function renderLegacyRunCard(state, options) {
941
941
  };
942
942
  return truncatedSkeleton;
943
943
  }
944
- /** Exact-match marker used in the card before the answer is sent separately. */
945
- export const FINAL_ANSWER_OVERFLOW_MARKER = '_最终回答较长,完整内容已随后发送_';
944
+ /** Exact-match marker: the body is delivered as an attached Markdown file. */
945
+ export const FINAL_ANSWER_OVERFLOW_MARKER = '_最终回答较长,完整内容见随附的 Markdown 文件_';
946
946
  /**
947
947
  * Build a card around the authoritative final answer.
948
948
  *
949
949
  * The answer is measured first with the fixed controls. Progress groups are
950
950
  * added newest-first only while the whole card still fits. If the answer
951
951
  * cannot fit even without progress/summary, the card carries an explicit
952
- * marker and the bridge sends the complete answer separately.
952
+ * marker and the bridge sends the complete answer as a Markdown file
953
+ * attachment (never as plain chat text).
954
+ *
955
+ * 「卡片装不下正文」与「表格数超上限」是两件事,只有前者才走补发通道:
956
+ * 飞书单卡最多 5 张 markdown 表(11310),多出来的最旧表格会被删掉并在原位
957
+ * 留 `_💡 前 N 个表格已省略_`——正文仍在卡片里,再补发一遍就是把用户已经看到
958
+ * 的整篇回答重复(2026-11-02 实机反馈)。
953
959
  */
954
960
  function buildFinalAnswerPriorityCard(state, options) {
955
961
  const finalAnswer = state.finalAnswer;
956
962
  const groups = groupBlocks(state.blocks);
957
963
  const prepared = prepareGroupContent(groups);
958
964
  const safeAnswer = truncateMarkdownTables(finalAnswer.content);
959
- const requiresOverflow = safeAnswer !== finalAnswer.content;
960
965
  const ts = formatTimestamp(finalAnswer.timestamp);
961
966
  const answerElements = renderTextBlock(safeAnswer, ts, state.terminal !== 'running');
962
967
  const fixedStart = [statusRow(state), ...omittedBlocksHint(state)];
@@ -970,7 +975,6 @@ function buildFinalAnswerPriorityCard(state, options) {
970
975
  return assembleRunCard(state, options, [
971
976
  ...fixedStart,
972
977
  ...progress,
973
- ...(requiresOverflow ? [markdownDiv(FINAL_ANSWER_OVERFLOW_MARKER)] : []),
974
978
  ...answerElements,
975
979
  ...(includeSummary ? summary : []),
976
980
  ...controls,
@@ -984,21 +988,15 @@ function buildFinalAnswerPriorityCard(state, options) {
984
988
  break;
985
989
  best = candidate;
986
990
  }
987
- return {
988
- card: best,
989
- finalAnswerStatus: requiresOverflow ? 'overflow' : 'full',
990
- ...(requiresOverflow ? { finalAnswerText: finalAnswer.content } : {}),
991
- };
991
+ // 正文已经在卡里(表格超限只是少渲染最旧的几张,原位有省略提示),
992
+ // 不再走补发通道。
993
+ return { card: best, finalAnswerStatus: 'full' };
992
994
  }
993
995
  // The answer outranks the usage summary. Keep the answer and controls first,
994
996
  // then retry without the summary before declaring a physical overflow.
995
997
  const withoutSummary = buildWithProgress(groups.length, false);
996
998
  if (withinRunCardBudget(withoutSummary)) {
997
- return {
998
- card: withoutSummary,
999
- finalAnswerStatus: requiresOverflow ? 'overflow' : 'full',
1000
- ...(requiresOverflow ? { finalAnswerText: finalAnswer.content } : {}),
1001
- };
999
+ return { card: withoutSummary, finalAnswerStatus: 'full' };
1002
1000
  }
1003
1001
  const overflowCard = assembleRunCard(state, options, [
1004
1002
  ...fixedStart,
@@ -1204,11 +1202,26 @@ const BACKGROUND_JOB_INLINE_MAX = 36;
1204
1202
  const BACKGROUND_JOB_FOLD_DISPLAY_MAX = 180;
1205
1203
  const BACKGROUND_JOB_FOLD_LINES = 6;
1206
1204
  /**
1207
- * 任务状态标签:目前 DSH 只会给 running/stopping,其他值按运行中处理。
1205
+ * 任务状态标签:宿主的两态 + 桥侧观测出的「已结束 / 状态未知」。
1206
+ *
1207
+ * 「已结束」不是可有可无的修饰:卡片上曾经只有「运行中/停止中」,任务跑完之
1208
+ * 后卡片仍然这么写,用户只能靠点一次停止按钮看它报错来判断任务是否还在。
1208
1209
  */
1209
1210
  function backgroundJobStatusLabel(status) {
1211
+ if (status === 'ended')
1212
+ return '已结束';
1213
+ if (status === 'unknown')
1214
+ return '状态未知';
1210
1215
  return status === 'stopping' ? '停止中' : '运行中';
1211
1216
  }
1217
+ /** 行尾状态(live 任务返回空串:运行中本来就是默认状态)。 */
1218
+ function backgroundJobStatusTail(status) {
1219
+ if (status === 'ended')
1220
+ return '✅ 已结束';
1221
+ if (status === 'unknown')
1222
+ return '⚠️ 状态未知';
1223
+ return '';
1224
+ }
1212
1225
  /**
1213
1226
  * 生成能包住任意反引号串的代码围栏。命令里的 shell 反引号不能替换成 `'`
1214
1227
  * (那是改内容),只能选比内容里最长反引号串更长的围栏。
@@ -1246,24 +1259,40 @@ function buildBackgroundJobsSection(state) {
1246
1259
  const jobs = state.backgroundJobs ?? [];
1247
1260
  if (jobs.length === 0)
1248
1261
  return [];
1249
- const elements = [
1250
- markdownDiv(`🧵 **后台任务** ${jobs.length} 个仍在运行`),
1251
- markdownDiv('_本轮结束后继续,可逐个停止_', 'notation'),
1252
- ];
1253
- jobs.slice(0, MAX_BACKGROUND_JOB_LINES).forEach((job, index) => {
1262
+ // 仍在跑的排前面:用户最先要知道的是"哪些还在占资源"。
1263
+ const running = jobs.filter((job) => isLiveBackgroundJob(job));
1264
+ const unknown = jobs.filter((job) => job.status === 'unknown');
1265
+ const ended = jobs.filter((job) => !isLiveBackgroundJob(job) && job.status !== 'unknown');
1266
+ const ordered = [...running, ...unknown, ...ended];
1267
+ // 计数逐维列出:只说「N 个任务」会让"都还在跑"与"都结束了"读起来一样。
1268
+ const counts = [];
1269
+ if (running.length > 0)
1270
+ counts.push(`${running.length} 个仍在运行`);
1271
+ if (unknown.length > 0)
1272
+ counts.push(`${unknown.length} 个状态未知`);
1273
+ if (ended.length > 0)
1274
+ counts.push(`${ended.length} 个已结束`);
1275
+ const headline = ended.length === jobs.length ? '已全部结束' : counts.join(' · ');
1276
+ const elements = [markdownDiv(`🧵 **后台任务** ${headline}`)];
1277
+ if (running.length + unknown.length > 0) {
1278
+ elements.push(markdownDiv('_本轮结束后继续,可逐个停止_', 'notation'));
1279
+ }
1280
+ ordered.slice(0, MAX_BACKGROUND_JOB_LINES).forEach((job, index) => {
1254
1281
  const label = job.label.trim();
1282
+ const tail = backgroundJobStatusTail(job.status);
1255
1283
  const lineCount = label === '' ? 0 : label.split(/\r?\n/).length;
1256
1284
  const inline = label.length > 0 &&
1257
1285
  lineCount === 1 &&
1258
1286
  !label.includes('`') &&
1259
1287
  displayWidth(label) <= BACKGROUND_JOB_INLINE_MAX;
1260
1288
  if (inline) {
1261
- elements.push(markdownDiv(`· \`${label}\``));
1289
+ elements.push(markdownDiv(`· \`${label}\`${tail ? ` · ${tail}` : ''}`));
1262
1290
  }
1263
1291
  else {
1264
1292
  // 多行/超宽/含反引号:代码块保留完整命令;只有超长才折叠。
1293
+ // 命令原文一个字都不改,状态出现在围栏之后。
1265
1294
  const fence = codeFenceFor(label);
1266
- const content = `${fence}\n${label}\n${fence}`;
1295
+ const content = `${fence}\n${label}\n${fence}${tail ? `\n\n${tail}` : ''}`;
1267
1296
  const fold = lineCount > BACKGROUND_JOB_FOLD_LINES ||
1268
1297
  displayWidth(label) > BACKGROUND_JOB_FOLD_DISPLAY_MAX;
1269
1298
  elements.push(fold
@@ -1276,6 +1305,9 @@ function buildBackgroundJobsSection(state) {
1276
1305
  })
1277
1306
  : markdownDiv(content));
1278
1307
  }
1308
+ // 已结束的任务不再给停止入口——那个按钮点下去只会回「可能已自行结束」。
1309
+ if (job.status === 'ended')
1310
+ return;
1279
1311
  elements.push({
1280
1312
  tag: 'column_set',
1281
1313
  columns: [
@@ -1,4 +1,4 @@
1
- import type { AgentEvent, ApprovalView, ToolHint, GoalView } from '../runner/index.js';
1
+ import type { AgentEvent, BackgroundJobRoster, ApprovalView, ToolHint, GoalView } from '../runner/index.js';
2
2
  import type { UsageSnapshot } from '../session-contract.js';
3
3
  export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout'
4
4
  /**
@@ -172,13 +172,11 @@ export interface RunState extends UsageSnapshot {
172
172
  /**
173
173
  * run 结束时仍在后台运行的任务。跨 turn 生命周期:卡片必须说清楚"这一轮
174
174
  * 结束了,但还有东西在跑",并给出逐个停止的入口。
175
+ *
176
+ * 收尾之后这份名单仍会被 `syncBackgroundJobs` 更新(宿主会继续推送任务
177
+ * 生命周期),所以它是"最后一次观测到的名单",不是"收尾那一刻的名单"。
175
178
  */
176
- backgroundJobs?: Array<{
177
- id: string;
178
- label: string;
179
- status?: string;
180
- progress?: string;
181
- }>;
179
+ backgroundJobs?: BackgroundJobRoster[];
182
180
  /**
183
181
  * 会话级长期目标(DSH goal)。跨 turn 生命周期:goal 仍 active 时,本轮结束
184
182
  * 只是"一轮结束",宿主会自动续跑,卡片必须显示目标是什么、跑到第几轮。
@@ -208,5 +206,34 @@ export declare function reduceRunState(state: RunState, event: AgentEvent): RunS
208
206
  * 写成「整体重建」,字段一多就会漏带。
209
207
  */
210
208
  export declare function removeBackgroundJob(state: RunState, jobId: string): RunState;
209
+ /**
210
+ * 该任务是否仍在跑(`ended` / `unknown` 都不算)。
211
+ *
212
+ * 卡片据此决定要不要渲染「⏹ 停止」:给已经结束的任务留一个点了必然失败的
213
+ * 按钮,等于逼用户拿「停止」当查询用。
214
+ */
215
+ export declare function isLiveBackgroundJob(job: BackgroundJobRoster): boolean;
216
+ /**
217
+ * 收尾后把宿主推来的整份任务名单同步进终态卡状态。
218
+ *
219
+ * 与 reducer 的分工:reducer 处理的是"收尾那一刻的名单",只在 run 还在消费
220
+ * 事件时有效;终态之后 `reduceRunState` 直接早退(终态幂等),名单只能由
221
+ * 这里更新。
222
+ *
223
+ * 三条规则:
224
+ * - 名单里消失 = 宿主已结算(`job/list` 是整份替换,摘掉即结束)→ `ended`;
225
+ * - 名单里还在、宿主却给了终态词 → `ended`(有些宿主会把已结束的行留在名单里);
226
+ * - `ended` 是终态,不回退成 running —— 同一个 job id 不会重新开始跑。
227
+ *
228
+ * 无变化时返回**同一个 state 引用**:bridge 靠它跳过无谓的卡片 patch。
229
+ */
230
+ export declare function syncBackgroundJobs(state: RunState, roster: ReadonlyArray<BackgroundJobRoster>): RunState;
231
+ /**
232
+ * 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」。
233
+ *
234
+ * 监控中断不等于任务结束——不标出来,卡片又会退化成"最后一次观测当现状",
235
+ * 正是这次要修的那个缺陷。未知态仍保留停止按钮(用户还能尝试停掉)。
236
+ */
237
+ export declare function markBackgroundJobsUnknown(state: RunState): RunState;
211
238
  export declare function finishRun(state: RunState, terminal: RunFinalTerminal, meta?: FinishMeta): RunState;
212
239
  export {};
@@ -43,6 +43,16 @@ export function createInitialRunState(runId) {
43
43
  };
44
44
  }
45
45
  export function reduceRunState(state, event) {
46
+ const next = reduceRunStateInner(state, event);
47
+ if (next === state)
48
+ return next;
49
+ // 每次迁移后归一:**只有最新一条 text 块**免于存储窗口——它是「输出」,
50
+ // 随时会被提升成最终回答;更早的正文退回进度窗口(内存与元素预算不变)。
51
+ const blocks = capStaleTextBlocks(next.blocks);
52
+ return blocks === next.blocks ? next : { ...next, blocks };
53
+ }
54
+ /** 单事件 → 单次状态迁移。窗口后置裁剪统一由 `reduceRunState` 做,这里不管。 */
55
+ function reduceRunStateInner(state, event) {
46
56
  // 只有终态才阻止事件处理。finalizing 是非终态,
47
57
  // 仍需累积 assistant 内容(thinking、text、tool_use、后台任务输出)。
48
58
  const terminal = state.terminal;
@@ -142,14 +152,16 @@ function reduceTurnDiffEvent(state, event) {
142
152
  const capped = upsertSnapshotBlock(blocks, (b) => b.kind === 'text' && b.itemId === event.itemId, () => ({
143
153
  kind: 'text',
144
154
  itemId: event.itemId,
145
- content: keepTailMarked(event.text, MAX_TEXT_CHARS),
155
+ // 最新那条正文不设窗口(它是「输出」),capStaleTextBlocks 负责在它被
156
+ // 更新的正文顶替后补上进度窗口。
157
+ content: event.text,
146
158
  timestamp: ts,
147
159
  ...(complete ? { completedAt: ts } : {}),
148
160
  }), (existing) => {
149
161
  const textBlock = existing;
150
162
  return {
151
163
  ...textBlock,
152
- content: keepTailMarked(event.text, MAX_TEXT_CHARS),
164
+ content: event.text,
153
165
  // kimi:块锚点时间改为本次 diff 时间(整轮累积流不断续写,
154
166
  // 标题应反映“最后写入时刻”而非首见时刻;move-to-end 后位置
155
167
  // 与标题才一致)。codex app-server 不置 refreshTimestamp,
@@ -333,8 +345,10 @@ function reduceResultEvent(state, event) {
333
345
  // sessionId is set by system.init; undefined means init hasn't arrived yet.
334
346
  if (state.sessionId === undefined)
335
347
  return state;
336
- const withFinalAnswer = promoteFinalAnswer(state, 'inferred');
337
348
  const resultEvent = event;
349
+ // runner 带过来的完整回答优先于 blocks 里的副本:副本可能是被窗口截过的
350
+ // (同一 turn 里回答之后又出现过更晚的正文),只能当兜底。
351
+ const withFinalAnswer = promoteFinalAnswer(state, 'inferred', resultEvent.finalAnswerText);
338
352
  const incomingSubtype = event.subtype;
339
353
  const incomingErrorMsg = incomingSubtype === 'error' ? (resultEvent.errorMessage ?? 'Agent 返回错误结果') : undefined;
340
354
  // 合并 subtype:error 优先,其次 interrupted(任一为 error 即 error;
@@ -385,13 +399,20 @@ function reduceFinalAnswerEvent(state, event) {
385
399
  * Agents without an explicit final-answer marker use their last non-empty
386
400
  * assistant text block. Moving it out of `blocks` protects it from
387
401
  * MAX_BLOCKS and lets the renderer assign it first claim on card space.
402
+ *
403
+ * `authoritativeText` 是 runner 在 result 上带来的完整回答(见
404
+ * `ResultEvent.finalAnswerText`)。blocks 里那条**通常**是完整的最新正文
405
+ * (见 capStaleTextBlocks),但它可能已被更晚的正文顶替而降级成窗口副本,
406
+ * 也可能在重连丢帧时缺内容;runner 给的全文优先级更高,是「输出永不截断」
407
+ * 的主数据源。
388
408
  */
389
- function promoteFinalAnswer(state, source) {
409
+ function promoteFinalAnswer(state, source, authoritativeText) {
390
410
  if (state.finalAnswer) {
391
411
  return state.finalAnswer.complete
392
412
  ? state
393
413
  : { ...state, finalAnswer: { ...state.finalAnswer, complete: true } };
394
414
  }
415
+ const fullText = authoritativeText?.trim() ? authoritativeText : undefined;
395
416
  for (let i = state.blocks.length - 1; i >= 0; i--) {
396
417
  const block = state.blocks[i];
397
418
  if (block.kind !== 'text' || !block.content.trim())
@@ -400,13 +421,18 @@ function promoteFinalAnswer(state, source) {
400
421
  ...state,
401
422
  blocks: state.blocks.filter((_, index) => index !== i),
402
423
  finalAnswer: {
403
- content: block.content,
424
+ content: fullText ?? block.content,
404
425
  timestamp: block.timestamp,
405
426
  complete: true,
406
427
  source,
407
428
  },
408
429
  };
409
430
  }
431
+ // 进度块全被 MAX_BLOCKS 淘汰、只有 runner 全文可用时也要建立 finalAnswer——
432
+ // 否则长回答的最后一段会连「输出」面板都不存在。
433
+ if (fullText) {
434
+ return { ...state, finalAnswer: { content: fullText, complete: true, source } };
435
+ }
410
436
  return state;
411
437
  }
412
438
  /**
@@ -434,6 +460,95 @@ export function removeBackgroundJob(state, jobId) {
434
460
  backgroundJobs: (state.backgroundJobs ?? []).filter((job) => job.id !== jobId),
435
461
  };
436
462
  }
463
+ /** 宿主仍在执行的任务状态词。状态缺失也算在跑——它还在名单上。 */
464
+ const RUNNING_JOB_STATUSES = new Set(['running', 'stopping']);
465
+ /**
466
+ * 该任务是否仍在跑(`ended` / `unknown` 都不算)。
467
+ *
468
+ * 卡片据此决定要不要渲染「⏹ 停止」:给已经结束的任务留一个点了必然失败的
469
+ * 按钮,等于逼用户拿「停止」当查询用。
470
+ */
471
+ export function isLiveBackgroundJob(job) {
472
+ return job.status === undefined || RUNNING_JOB_STATUSES.has(job.status);
473
+ }
474
+ /** 宿主状态 → 桥侧状态:仍在跑的保留原词,其余已知终态归一成 `ended`。 */
475
+ function bridgeJobStatus(status) {
476
+ return RUNNING_JOB_STATUSES.has(status) ? status : 'ended';
477
+ }
478
+ /**
479
+ * 收尾后把宿主推来的整份任务名单同步进终态卡状态。
480
+ *
481
+ * 与 reducer 的分工:reducer 处理的是"收尾那一刻的名单",只在 run 还在消费
482
+ * 事件时有效;终态之后 `reduceRunState` 直接早退(终态幂等),名单只能由
483
+ * 这里更新。
484
+ *
485
+ * 三条规则:
486
+ * - 名单里消失 = 宿主已结算(`job/list` 是整份替换,摘掉即结束)→ `ended`;
487
+ * - 名单里还在、宿主却给了终态词 → `ended`(有些宿主会把已结束的行留在名单里);
488
+ * - `ended` 是终态,不回退成 running —— 同一个 job id 不会重新开始跑。
489
+ *
490
+ * 无变化时返回**同一个 state 引用**:bridge 靠它跳过无谓的卡片 patch。
491
+ */
492
+ export function syncBackgroundJobs(state, roster) {
493
+ const tracked = state.backgroundJobs ?? [];
494
+ const byId = new Map(roster.map((job) => [job.id, job]));
495
+ const next = tracked.map((job) => {
496
+ const row = byId.get(job.id);
497
+ if (!row)
498
+ return job.status === 'ended' ? job : { ...job, status: 'ended' };
499
+ if (job.status === 'ended')
500
+ return job;
501
+ const status = row.status === undefined ? job.status : bridgeJobStatus(row.status);
502
+ const label = row.label.trim() === '' ? job.label : row.label;
503
+ const progress = row.progress ?? job.progress;
504
+ if (status === job.status && label === job.label && progress === job.progress)
505
+ return job;
506
+ return {
507
+ ...job,
508
+ label,
509
+ ...(status === undefined ? {} : { status }),
510
+ ...(progress === undefined ? {} : { progress }),
511
+ };
512
+ });
513
+ // 新出现的任务只收仍在跑的:与收尾采集同一口径(列出已结束的任务只会被
514
+ // 读成"还在跑")。
515
+ const seen = new Set(tracked.map((job) => job.id));
516
+ for (const row of roster) {
517
+ if (seen.has(row.id))
518
+ continue;
519
+ if (row.status !== undefined && !RUNNING_JOB_STATUSES.has(row.status))
520
+ continue;
521
+ next.push({
522
+ id: row.id,
523
+ label: row.label,
524
+ ...(row.status === undefined ? {} : { status: row.status }),
525
+ ...(row.progress === undefined ? {} : { progress: row.progress }),
526
+ });
527
+ }
528
+ if (next.length === tracked.length && next.every((job, index) => job === tracked[index])) {
529
+ return state;
530
+ }
531
+ return { ...state, backgroundJobs: next };
532
+ }
533
+ /**
534
+ * 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」。
535
+ *
536
+ * 监控中断不等于任务结束——不标出来,卡片又会退化成"最后一次观测当现状",
537
+ * 正是这次要修的那个缺陷。未知态仍保留停止按钮(用户还能尝试停掉)。
538
+ */
539
+ export function markBackgroundJobsUnknown(state) {
540
+ const jobs = state.backgroundJobs;
541
+ if (!jobs || jobs.length === 0)
542
+ return state;
543
+ let changed = false;
544
+ const next = jobs.map((job) => {
545
+ if (!isLiveBackgroundJob(job))
546
+ return job;
547
+ changed = true;
548
+ return { ...job, status: 'unknown' };
549
+ });
550
+ return changed ? { ...state, backgroundJobs: next } : state;
551
+ }
437
552
  /**
438
553
  * 运行期状态投影 → 状态行。
439
554
  *
@@ -500,12 +615,14 @@ function reduceAssistantEvent(state, event) {
500
615
  else if (content.type === 'text') {
501
616
  const blocks = markThinkingInactive(next.blocks);
502
617
  const last = blocks[blocks.length - 1];
618
+ // 最新正文原样保留(见 capStaleTextBlocks):这里一旦按窗口截断,最终回答
619
+ // 就在进渲染层之前已经缺头,任何「回答优先」的排版都救不回来。
503
620
  const newBlocks = last?.kind === 'text'
504
621
  ? [
505
622
  ...blocks.slice(0, -1),
506
623
  {
507
624
  kind: 'text',
508
- content: keepTailMarked(last.content + content.text, MAX_TEXT_CHARS),
625
+ content: last.content + content.text,
509
626
  timestamp: last.timestamp ?? event.timestamp,
510
627
  },
511
628
  ]
@@ -513,7 +630,7 @@ function reduceAssistantEvent(state, event) {
513
630
  ...blocks,
514
631
  {
515
632
  kind: 'text',
516
- content: keepTailMarked(content.text, MAX_TEXT_CHARS),
633
+ content: content.text,
517
634
  timestamp: event.timestamp,
518
635
  },
519
636
  ];
@@ -758,6 +875,42 @@ function keepLatestBlocks(blocks) {
758
875
  }
759
876
  /** 截断标记前缀(`keepTailMarked` 产出,含已丢弃正文计数)。 */
760
877
  const TRUNCATION_MARKER_RE = /^…(前 (\d+) 字符已省略)\n/;
878
+ /**
879
+ * 给「已经不再是最新」的 text 块补上 `MAX_TEXT_CHARS` 存储窗口。
880
+ *
881
+ * 为什么最新一条不截:它就是卡片上的**输出**,终态还会被 `promoteFinalAnswer`
882
+ * 提成最终回答。提前按窗口切掉头部,渲染层再怎么回答优先也补不回来(2026-11-01
883
+ * 输出块被截断缺陷:16074 字符的回答只进到渲染层 12000 字符,且是从中间开始的)。
884
+ *
885
+ * 为什么其余 text 块仍然截:它们只是进度信息,卡片本来就只会展示最新几步;
886
+ * 存储层必须保持有界(`MAX_BLOCKS` × 窗口),否则长 run 的内存随历史线性增长。
887
+ *
888
+ * 判定用「最后一次出现的 text 块」而不是「最后一个块」:工具/思考出现在正文之后
889
+ * 时(例如回答写完再补一次思考),回答仍然是那张卡要展示的输出,不能因此被切。
890
+ */
891
+ function capStaleTextBlocks(blocks) {
892
+ let newestText = -1;
893
+ for (let i = blocks.length - 1; i >= 0; i--) {
894
+ if (blocks[i].kind === 'text') {
895
+ newestText = i;
896
+ break;
897
+ }
898
+ }
899
+ if (newestText < 0)
900
+ return blocks;
901
+ let changed = false;
902
+ const capped = blocks.map((block, index) => {
903
+ if (index === newestText || block.kind !== 'text')
904
+ return block;
905
+ const content = block.content;
906
+ const windowed = keepTailMarked(content, MAX_TEXT_CHARS);
907
+ if (windowed === content)
908
+ return block;
909
+ changed = true;
910
+ return { ...block, content: windowed };
911
+ });
912
+ return changed ? capped : blocks;
913
+ }
761
914
  /**
762
915
  * keepTail + 截断标记:被去头的存储内容以前缀如实标注(信息保真红线:禁止静默丢弃)。
763
916
  * 标记计入 maxChars 预算(存储总量仍 ≤ maxChars,保持既有长度不变量)。
@@ -51,6 +51,14 @@ export interface DshSessionSummary {
51
51
  running: boolean;
52
52
  blank: boolean;
53
53
  cwd?: string;
54
+ /**
55
+ * `'subagent'` for a session derived by delegation; absent for top-level
56
+ * sessions. Present since DSH 0.2.x, which lists subagent sessions next to
57
+ * their parent (same cwd, bare-uuid id, `updatedAt` = delegation end).
58
+ */
59
+ origin?: string;
60
+ /** Parent session of a subagent (absent on top-level sessions). */
61
+ parentSessionId?: string;
54
62
  /** Legacy top-level preset (pre-0.2 DSH). */
55
63
  agentPreset?: string;
56
64
  /** 0.2.x exposes preset/title through projections. */
@@ -207,7 +207,9 @@ export function setupMessageHandlers(deps) {
207
207
  const actionValue = action.action.value;
208
208
  if (!actionValue?.cmd)
209
209
  return;
210
- logger.info(`card action: ${actionValue.cmd}`);
210
+ logger.info(actionValue.cmd === 'stop'
211
+ ? `card action: stop runId=${actionValue.runId ?? '(none)'} messageId=${action.messageId}`
212
+ : `card action: ${actionValue.cmd}`);
211
213
  const userId = action.operator.openId;
212
214
  const chatId = action.chatId;
213
215
  const messageId = action.messageId;
@@ -1319,14 +1319,15 @@ export class CommandRouter {
1319
1319
  * Handle stop card action: interrupt current run.
1320
1320
  */
1321
1321
  async handleCardStop(value, ctx) {
1322
- if (!value.runId || !ctx.userId || !ctx.chatId) {
1322
+ if (!ctx.userId || !ctx.chatId || (!value.runId && !ctx.messageId)) {
1323
1323
  await this.bridge.sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, ctx);
1324
1324
  return;
1325
1325
  }
1326
1326
  const interrupt = await this.bridge.interruptCurrentRunDetailed({
1327
1327
  userId: ctx.userId,
1328
1328
  chatId: ctx.chatId,
1329
- runId: value.runId,
1329
+ ...(value.runId ? { runId: value.runId } : {}),
1330
+ cardMessageId: ctx.messageId,
1330
1331
  });
1331
1332
  const stopped = interrupt.stopped;
1332
1333
  // Regression 2026-06-22: card stop button silently no-op'd when the
@@ -325,13 +325,20 @@ export declare class DshClient {
325
325
  */
326
326
  controlFrames(signal: AbortSignal): AsyncGenerator<DshProjectionItem>;
327
327
  /**
328
- * One session's current background-job roster.
328
+ * Subscribe to one session's background-job roster as a live stream.
329
329
  *
330
- * `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
331
- * 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
332
- * 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
330
+ * `job/list` 是整份替换流(whole-set replacement):首帧就是当前真相,之后
331
+ * 任务生命周期一变就再推一整份。**run 结束之后**必须用这个长订阅——后台任务
332
+ * 跨 turn 存活,任务结束这件事只会在这条流上出现;收尾时取首帧即断开
333
+ * ({@link jobRows})只够画那一瞬间的卡片。
334
+ */
335
+ watchJobs(sessionId: string, signal: AbortSignal): AsyncGenerator<DshJobView[]>;
336
+ /**
337
+ * One session's current background-job roster (snapshot).
333
338
  *
334
- * 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
339
+ * "此刻有哪些任务在跑"只需要第一帧:文档明确首帧就是当前真相,取到即断开,
340
+ * 不长期持有连接。失败返回 undefined(与"没有任务"区分):拿不到就别在卡片
341
+ * 上写"0 个后台任务"。
335
342
  */
336
343
  jobRows(sessionId: string, opts?: {
337
344
  signal?: AbortSignal;
@@ -927,25 +927,35 @@ export class DshClient {
927
927
  }
928
928
  }
929
929
  /**
930
- * One session's current background-job roster.
930
+ * Subscribe to one session's background-job roster as a live stream.
931
931
  *
932
- * `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
933
- * 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
934
- * 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
932
+ * `job/list` 是整份替换流(whole-set replacement):首帧就是当前真相,之后
933
+ * 任务生命周期一变就再推一整份。**run 结束之后**必须用这个长订阅——后台任务
934
+ * 跨 turn 存活,任务结束这件事只会在这条流上出现;收尾时取首帧即断开
935
+ * ({@link jobRows})只够画那一瞬间的卡片。
936
+ */
937
+ async *watchJobs(sessionId, signal) {
938
+ for await (const frame of this.muxStream('job/list', { args: dshArgs('job/list', { sessionId }) }, signal)) {
939
+ const rows = frame;
940
+ if (rows?.type !== 'rows')
941
+ continue;
942
+ yield Array.isArray(rows.jobs) ? rows.jobs : [];
943
+ }
944
+ }
945
+ /**
946
+ * One session's current background-job roster (snapshot).
935
947
  *
936
- * 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
948
+ * "此刻有哪些任务在跑"只需要第一帧:文档明确首帧就是当前真相,取到即断开,
949
+ * 不长期持有连接。失败返回 undefined(与"没有任务"区分):拿不到就别在卡片
950
+ * 上写"0 个后台任务"。
937
951
  */
938
952
  async jobRows(sessionId, opts = {}) {
939
953
  const timer = new AbortController();
940
954
  const timeout = setTimeout(() => timer.abort(), opts.timeoutMs ?? 2_000);
941
955
  const signal = opts.signal ? AbortSignal.any([opts.signal, timer.signal]) : timer.signal;
942
956
  try {
943
- for await (const frame of this.muxStream('job/list', { args: dshArgs('job/list', { sessionId }) }, signal)) {
944
- const rows = frame;
945
- if (rows?.type === 'rows') {
946
- return Array.isArray(rows.jobs) ? rows.jobs : [];
947
- }
948
- }
957
+ for await (const rows of this.watchJobs(sessionId, signal))
958
+ return rows;
949
959
  return undefined;
950
960
  }
951
961
  catch (err) {