lark-remote 0.5.17 → 0.5.18

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.18] - 2026-11-02
9
+
10
+ ### 修复
11
+
12
+ - **超长最终回答不再在卡片里被拦腰截断**:运行卡的状态层对正文有 12,000 字符的窗口,此前这个窗口对**最新一条**正文同样生效,超出窗口的回答只剩尾部一截、还从句子中间开始。现在最新一条正文不设窗口(它就是要展示的输出),只有被更晚的正文顶替之后才按窗口截断留痕;回合结束时优先采用运行器手里的完整正文——DSH 在回合结束交出整条 assistant 消息,不依赖增量拼接,重连丢帧也不缺头。六个 agent 各用自己的真实转换器回放同一篇超长回答做回归
13
+ - **表格超限不再被误判成「卡片装不下」而重复补发完整回答**:最终回答里的表格超过单卡上限时,渲染器只是省略最旧的几张并在原位留一句提示,正文其实完整地画在了卡上;此前这句提示被当成溢出信号,卡片照常渲染正文之外又补发一条「完整回答」,同一篇回答用户看到两遍。现在正文仍在卡内一律按「完整」上报,只有正文真被占位替代时才走补发通道
14
+ - **DSH 自动恢复不再选中子 agent 派生会话**:DSH 0.2.x 把子 agent 派生的会话也列进会话列表(同一个工作目录、更新时间取委托结束时刻,常比父会话还新),按「最新会话」自动恢复于是选中了它;这类会话不接受按顶层会话寻址,读取被服务端拒绝后又被吞成空事件——切换工作目录后的恢复卡只剩一行标题,还绑在一个续跑不了的会话上,下一条消息必然报错。现在列表与寻址统一排除这类会话(判定只认来源标记:会话 id 的形态和父会话字段都会误伤正常会话)
15
+
16
+ ### 变更
17
+
18
+ - **完整回答溢出改走 Markdown 文件,最终回答不再发纯文本**:正文装不进一张卡时,把完整回答逐字写成临时 `.md` 文件随卡片发出,发送后尽力清理;卡上的提示改为「完整内容见随附的 Markdown 文件」。原先拆成「完整回答(1/N)」多条纯文本有两个硬伤:与卡片正文重复,且丢掉表格与代码块结构。文件发送失败时只发提示卡,并在日志里留落盘路径
19
+
20
+ ### 维护
21
+
22
+ - **用例头注释不再点名已删除的测试文件**:15 个锚点用例头部的「合并自 …」列的是早前测试收敛时删掉的旧文件名,读者按名去找会落到不存在的路径;只保留「本文件覆盖什么」,需要指路的改指向仍然存在的共享 fixture。无行为变化
23
+
8
24
  ## [0.5.17] - 2026-11-01
9
25
 
10
26
  ### 维护
@@ -373,11 +373,14 @@ export declare class Bridge {
373
373
  /** Send a result message to the user (§7). Returns true on success, false on failure. */
374
374
  sendResult(result: CommandResult, ctx: CommandContext): Promise<boolean>;
375
375
  /**
376
- * Deliver a final answer that cannot fit in the run card. Plain text avoids
377
- * markdown parser limits and preserves every character; sequence labels make
378
- * multi-message delivery unambiguous.
376
+ * Deliver a final answer that cannot fit in the run card as a Markdown file
377
+ * attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
378
+ * (2026-11-02 定规:纯文本与卡片重复,也丢 Markdown 结构)。
379
+ *
380
+ * 失败时发一张提示卡(不是纯文本),并把完整回答留在临时文件里记日志,
381
+ * 便于事后找回。
379
382
  */
380
- private sendFinalAnswerOverflow;
383
+ private sendFinalAnswerFile;
381
384
  /**
382
385
  * Update an existing card in place (原地更新) instead of sending a new one.
383
386
  *
@@ -5,7 +5,8 @@ import { resolveFinalUsage, errorMessage } from './final-usage.js';
5
5
  import { getLogger } from '../logger/index.js';
6
6
  import { RunCardSession } from '../card/run-card-session.js';
7
7
  import { renderRunCard, renderRunCardDetailed, } from '../card/run-renderer.js';
8
- import { splitFinalAnswer } from '../card/final-answer-overflow.js';
8
+ import { writeFinalAnswerFile, finalAnswerFileFailureCard } from '../card/final-answer-file.js';
9
+ import { bestEffortUnlink } from '../common/fs.js';
9
10
  import { renderBashCard } from '../card/bash-renderer.js';
10
11
  import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
11
12
  import { isRunActive, isRunTerminal, removeBackgroundJob, } from '../card/run-state.js';
@@ -842,26 +843,26 @@ export class Bridge {
842
843
  }
843
844
  }
844
845
  /**
845
- * Deliver a final answer that cannot fit in the run card. Plain text avoids
846
- * markdown parser limits and preserves every character; sequence labels make
847
- * multi-message delivery unambiguous.
846
+ * Deliver a final answer that cannot fit in the run card as a Markdown file
847
+ * attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
848
+ * (2026-11-02 定规:纯文本与卡片重复,也丢 Markdown 结构)。
849
+ *
850
+ * 失败时发一张提示卡(不是纯文本),并把完整回答留在临时文件里记日志,
851
+ * 便于事后找回。
848
852
  */
849
- async sendFinalAnswerOverflow(text, ctx) {
850
- const chunks = splitFinalAnswer(text);
853
+ async sendFinalAnswerFile(text, ctx) {
854
+ let filePath;
851
855
  try {
852
- for (let i = 0; i < chunks.length; i++) {
853
- const prefix = chunks.length > 1 ? `完整回答(${i + 1}/${chunks.length})\n\n` : '完整回答\n\n';
854
- await this.connector.sendWithRetry(ctx.chatId, { text: prefix + chunks[i] }, { replyTo: ctx.messageId });
855
- }
856
+ filePath = writeFinalAnswerFile(text);
857
+ await this.connector.sendFile(ctx.chatId, filePath);
856
858
  }
857
859
  catch (err) {
858
- getLogger().error('[lark-remote] failed to deliver complete final answer:', err);
859
- try {
860
- await this.connector.sendWithRetry(ctx.chatId, { text: `⚠️ 完整回答补发失败:${errorMessage(err)}` }, { replyTo: ctx.messageId });
861
- }
862
- catch (fallbackErr) {
863
- getLogger().error('[lark-remote] final-answer failure notice also failed:', fallbackErr);
864
- }
860
+ getLogger().error(`[lark-remote] failed to deliver complete final answer as file (kept at ${filePath ?? 'not written'}):`, err);
861
+ await this.sendResult({ card: finalAnswerFileFailureCard(errorMessage(err)) }, ctx);
862
+ }
863
+ finally {
864
+ if (filePath)
865
+ bestEffortUnlink(filePath);
865
866
  }
866
867
  }
867
868
  /**
@@ -1613,7 +1614,7 @@ export class Bridge {
1613
1614
  await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind, cardSession.currentState.terminal);
1614
1615
  }
1615
1616
  if (finalRender.finalAnswerStatus === 'overflow' && finalRender.finalAnswerText) {
1616
- await this.sendFinalAnswerOverflow(finalRender.finalAnswerText, ctx);
1617
+ await this.sendFinalAnswerFile(finalRender.finalAnswerText, ctx);
1617
1618
  }
1618
1619
  // Add a terminal-state emoji reaction to the user's original message
1619
1620
  this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 把完整回答写进系统临时目录里的 `.md` 文件,返回绝对路径。
3
+ *
4
+ * 文件名带时间戳与随机后缀:同一分钟内的多个 run 不会互相覆盖,也不会在
5
+ * 飞书里显示成同名附件。内容逐字写入,不做任何截断或转义。
6
+ */
7
+ export declare function writeFinalAnswerFile(text: string, now?: Date): string;
8
+ /** 文件通道失败时的提示卡——终态反馈也只走卡片,不发纯文本。 */
9
+ export declare function finalAnswerFileFailureCard(message: string): object;
@@ -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
@@ -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 {};
@@ -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,
@@ -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
  /**
@@ -500,12 +526,14 @@ function reduceAssistantEvent(state, event) {
500
526
  else if (content.type === 'text') {
501
527
  const blocks = markThinkingInactive(next.blocks);
502
528
  const last = blocks[blocks.length - 1];
529
+ // 最新正文原样保留(见 capStaleTextBlocks):这里一旦按窗口截断,最终回答
530
+ // 就在进渲染层之前已经缺头,任何「回答优先」的排版都救不回来。
503
531
  const newBlocks = last?.kind === 'text'
504
532
  ? [
505
533
  ...blocks.slice(0, -1),
506
534
  {
507
535
  kind: 'text',
508
- content: keepTailMarked(last.content + content.text, MAX_TEXT_CHARS),
536
+ content: last.content + content.text,
509
537
  timestamp: last.timestamp ?? event.timestamp,
510
538
  },
511
539
  ]
@@ -513,7 +541,7 @@ function reduceAssistantEvent(state, event) {
513
541
  ...blocks,
514
542
  {
515
543
  kind: 'text',
516
- content: keepTailMarked(content.text, MAX_TEXT_CHARS),
544
+ content: content.text,
517
545
  timestamp: event.timestamp,
518
546
  },
519
547
  ];
@@ -758,6 +786,42 @@ function keepLatestBlocks(blocks) {
758
786
  }
759
787
  /** 截断标记前缀(`keepTailMarked` 产出,含已丢弃正文计数)。 */
760
788
  const TRUNCATION_MARKER_RE = /^…(前 (\d+) 字符已省略)\n/;
789
+ /**
790
+ * 给「已经不再是最新」的 text 块补上 `MAX_TEXT_CHARS` 存储窗口。
791
+ *
792
+ * 为什么最新一条不截:它就是卡片上的**输出**,终态还会被 `promoteFinalAnswer`
793
+ * 提成最终回答。提前按窗口切掉头部,渲染层再怎么回答优先也补不回来(2026-11-01
794
+ * 输出块被截断缺陷:16074 字符的回答只进到渲染层 12000 字符,且是从中间开始的)。
795
+ *
796
+ * 为什么其余 text 块仍然截:它们只是进度信息,卡片本来就只会展示最新几步;
797
+ * 存储层必须保持有界(`MAX_BLOCKS` × 窗口),否则长 run 的内存随历史线性增长。
798
+ *
799
+ * 判定用「最后一次出现的 text 块」而不是「最后一个块」:工具/思考出现在正文之后
800
+ * 时(例如回答写完再补一次思考),回答仍然是那张卡要展示的输出,不能因此被切。
801
+ */
802
+ function capStaleTextBlocks(blocks) {
803
+ let newestText = -1;
804
+ for (let i = blocks.length - 1; i >= 0; i--) {
805
+ if (blocks[i].kind === 'text') {
806
+ newestText = i;
807
+ break;
808
+ }
809
+ }
810
+ if (newestText < 0)
811
+ return blocks;
812
+ let changed = false;
813
+ const capped = blocks.map((block, index) => {
814
+ if (index === newestText || block.kind !== 'text')
815
+ return block;
816
+ const content = block.content;
817
+ const windowed = keepTailMarked(content, MAX_TEXT_CHARS);
818
+ if (windowed === content)
819
+ return block;
820
+ changed = true;
821
+ return { ...block, content: windowed };
822
+ });
823
+ return changed ? capped : blocks;
824
+ }
761
825
  /**
762
826
  * keepTail + 截断标记:被去头的存储内容以前缀如实标注(信息保真红线:禁止静默丢弃)。
763
827
  * 标记计入 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. */
@@ -36,6 +36,14 @@ export declare class DshTranslator {
36
36
  private hasStreamUsage;
37
37
  /** 当前 attempt 的 id(start 帧携带,chunk 帧也带;断开重连重放时不变)。 */
38
38
  private attemptId;
39
+ /**
40
+ * 本 turn 末条 durable assistant 消息的正文(**完整**、未截断)。
41
+ *
42
+ * live chunk 是逐 delta 拼接、会被状态层的 MAX_TEXT_CHARS 窗口截断;
43
+ * durable `assistant/message` 一次就到齐全文。收 turn 时把它挂在 result 上,
44
+ * 卡片才能渲染完整回答(2026-11-01 输出块被截断缺陷)。
45
+ */
46
+ private lastMessageText?;
39
47
  /** Translate one live assistant-stream chunk into AgentEvents. */
40
48
  streamFrameToAgentEvents(frame: DshAssistantStreamFrame, _sessionId: string): AgentEvent[];
41
49
  /**
@@ -34,6 +34,14 @@ export class DshTranslator {
34
34
  hasStreamUsage = false;
35
35
  /** 当前 attempt 的 id(start 帧携带,chunk 帧也带;断开重连重放时不变)。 */
36
36
  attemptId = '0';
37
+ /**
38
+ * 本 turn 末条 durable assistant 消息的正文(**完整**、未截断)。
39
+ *
40
+ * live chunk 是逐 delta 拼接、会被状态层的 MAX_TEXT_CHARS 窗口截断;
41
+ * durable `assistant/message` 一次就到齐全文。收 turn 时把它挂在 result 上,
42
+ * 卡片才能渲染完整回答(2026-11-01 输出块被截断缺陷)。
43
+ */
44
+ lastMessageText;
37
45
  /** Translate one live assistant-stream chunk into AgentEvents. */
38
46
  streamFrameToAgentEvents(frame, _sessionId) {
39
47
  if (frame.type === 'start') {
@@ -116,6 +124,10 @@ export class DshTranslator {
116
124
  if (raw && typeof raw === 'object') {
117
125
  this.recordMessageUsage(raw);
118
126
  }
127
+ // 每条 durable 消息都带完整正文;记最后一条非空的就是本轮回答。
128
+ const text = messageText(ev.data.message);
129
+ if (text.trim())
130
+ this.lastMessageText = text;
119
131
  return [];
120
132
  }
121
133
  case 'tool/call': {
@@ -188,6 +200,9 @@ export class DshTranslator {
188
200
  ? `turn ended with reason: ${reason}: ${detail}`
189
201
  : `turn ended with reason: ${reason}`;
190
202
  }
203
+ if (this.lastMessageText) {
204
+ result.finalAnswerText = this.lastMessageText;
205
+ }
191
206
  return [result];
192
207
  }
193
208
  default:
@@ -214,6 +229,27 @@ function parseToolArguments(raw) {
214
229
  return trimmed;
215
230
  }
216
231
  }
232
+ /**
233
+ * 取 `assistant/message.message.content` 里的正文(type==='text' 的块)。
234
+ *
235
+ * 只收 text 块:reasoning 走独立面板(且另有 4000 字符窗口),tool-call 由
236
+ * `tool/call` 事件承载。块之间直接拼接、不插分隔符——同一条消息里的多个 text
237
+ * 块本来就是一段连续回答。
238
+ */
239
+ function messageText(message) {
240
+ const content = message?.content;
241
+ if (!Array.isArray(content))
242
+ return '';
243
+ const parts = [];
244
+ for (const block of content) {
245
+ if (!block || typeof block !== 'object')
246
+ continue;
247
+ const rec = block;
248
+ if (rec.type === 'text' && typeof rec.text === 'string')
249
+ parts.push(rec.text);
250
+ }
251
+ return parts.join('');
252
+ }
217
253
  /**
218
254
  * Flatten a `tool/result.message.content` block list into display text.
219
255
  *
@@ -294,6 +294,17 @@ export interface ResultEvent {
294
294
  subtype: 'success' | 'error' | 'interrupted';
295
295
  session_id: string;
296
296
  timestamp?: string;
297
+ /**
298
+ * 本 run 的权威最终回答全文(runner 手里有一份完整文本时才填)。
299
+ *
300
+ * 为什么必须由 runner 带过来:状态层对进度正文有 `MAX_TEXT_CHARS`(12000
301
+ * 字符)存储窗口,最终回答超过窗口时 blocks 里只剩尾部 + `…(前 N 字符已
302
+ * 省略)` 标记——渲染层再怎么「回答优先」也找不回被丢掉的头部。runner 在
303
+ * 收 turn 时拿到的是**完整** assistant 文本(一条消息,不是逐 delta 拼接),
304
+ * 把它挂在 result 上,`promoteFinalAnswer` 用它建立 finalAnswer,回答因此
305
+ * 不再被进度窗口截断(2026-11-01 卡片输出块被截断缺陷)。
306
+ */
307
+ finalAnswerText?: string;
297
308
  usage?: {
298
309
  /** Non-cached input tokens. For codex this is raw `input_tokens` minus
299
310
  * `cached_input_tokens`; for pi/claude/opencode the raw value is already
@@ -7,8 +7,8 @@
7
7
  * — a synchronous HTTP round-trip to the local DSH host. This keeps the
8
8
  * router/bridge interface contract intact without touching any upstream logic.
9
9
  *
10
- * listSessions: session/list → filter by cwd → sort by updatedAt desc → local
11
- * pagination (DSH v1 has no cursor pagination).
10
+ * listSessions: session/list → 排除 subagent 派生会话 → filter by cwd → sort by
11
+ * updatedAt desc → local pagination (DSH v1 has no cursor pagination).
12
12
  * readSessionContent: session/projections → session/page → map SessionEvents +
13
13
  * session-wide usage accumulation (assistant/message.usage);本 run 的 flow
14
14
  * 字段 = 末个 turn(turn/start–turn/end)内所有采样之和,与 codex 同口径。
@@ -77,6 +77,11 @@ export declare class DshSessionReader implements AgentSessionReader {
77
77
  * Find the session row in `session/list` that matches BOTH id and cwd.
78
78
  * Returns undefined when the id belongs to another workspace (or is unknown) —
79
79
  * every caller treats that as "not usable here".
80
+ *
81
+ * subagent 派生会话也排除:它的 id + cwd 都能命中,但只能按
82
+ * `{kind:'subagent', parentSessionId, childSessionId}` 寻址,按顶层会话读会被
83
+ * 服务端拒绝(session/agent-busy)。在这里统一 fail-closed,比让每个调用方
84
+ * 各自吃一次拒绝异常更省事(那异常会被 catch 成空事件列表 = 一张空卡)。
80
85
  */
81
86
  private resolveSession;
82
87
  /** `session/page` needs an inclusive cursor; projections carry the watermark. */
@@ -7,8 +7,8 @@
7
7
  * — a synchronous HTTP round-trip to the local DSH host. This keeps the
8
8
  * router/bridge interface contract intact without touching any upstream logic.
9
9
  *
10
- * listSessions: session/list → filter by cwd → sort by updatedAt desc → local
11
- * pagination (DSH v1 has no cursor pagination).
10
+ * listSessions: session/list → 排除 subagent 派生会话 → filter by cwd → sort by
11
+ * updatedAt desc → local pagination (DSH v1 has no cursor pagination).
12
12
  * readSessionContent: session/projections → session/page → map SessionEvents +
13
13
  * session-wide usage accumulation (assistant/message.usage);本 run 的 flow
14
14
  * 字段 = 末个 turn(turn/start–turn/end)内所有采样之和,与 codex 同口径。
@@ -198,6 +198,7 @@ export class DshSessionReader {
198
198
  const value = this.unary('session/list', {});
199
199
  const items = value.items ?? [];
200
200
  const filtered = items
201
+ .filter((i) => !isSubagentSession(i))
201
202
  .filter((i) => i.cwd !== undefined && samePath(i.cwd, cwd))
202
203
  .map((i) => ({
203
204
  sessionId: String(i.sessionId),
@@ -374,10 +375,18 @@ export class DshSessionReader {
374
375
  * Find the session row in `session/list` that matches BOTH id and cwd.
375
376
  * Returns undefined when the id belongs to another workspace (or is unknown) —
376
377
  * every caller treats that as "not usable here".
378
+ *
379
+ * subagent 派生会话也排除:它的 id + cwd 都能命中,但只能按
380
+ * `{kind:'subagent', parentSessionId, childSessionId}` 寻址,按顶层会话读会被
381
+ * 服务端拒绝(session/agent-busy)。在这里统一 fail-closed,比让每个调用方
382
+ * 各自吃一次拒绝异常更省事(那异常会被 catch 成空事件列表 = 一张空卡)。
377
383
  */
378
384
  resolveSession(sessionId, cwd) {
379
385
  const value = this.unary('session/list', {});
380
- return (value.items ?? []).find((i) => String(i.sessionId) === sessionId && i.cwd !== undefined && samePath(i.cwd, cwd));
386
+ return (value.items ?? []).find((i) => !isSubagentSession(i) &&
387
+ String(i.sessionId) === sessionId &&
388
+ i.cwd !== undefined &&
389
+ samePath(i.cwd, cwd));
381
390
  }
382
391
  /** `session/page` needs an inclusive cursor; projections carry the watermark. */
383
392
  resolveThroughSeq(sessionId, known) {
@@ -387,6 +396,18 @@ export class DshSessionReader {
387
396
  return typeof known.projections?.asOfSeq === 'number' ? known.projections.asOfSeq : -1;
388
397
  }
389
398
  }
399
+ /**
400
+ * subagent 派生会话判定(DSH 0.2.x 起与顶层会话并列在 `session/list` 里)。
401
+ *
402
+ * 只认 `origin === 'subagent'`,别用更"顺手"的两个信号:
403
+ * - **id 前缀**:顶层是 `session-<uuid>`、subagent 是裸 `<uuid>`,但 v0 老会话
404
+ * 也是裸 uuid 且能正常读(`delegationDepth: 0`、无 `origin`);
405
+ * - **`parentSessionId` 非空**:fork 与 `session-btw-*` 旁支同样带 parent,却是
406
+ * 可恢复的顶层会话。
407
+ */
408
+ function isSubagentSession(item) {
409
+ return item.origin === 'subagent';
410
+ }
390
411
  /** Preset moved from the summary top level to projections in DSH 0.2.x. */
391
412
  function sessionPreset(item) {
392
413
  const preset = item.projections?.values?.agentPreset ?? item.agentPreset;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1 +0,0 @@
1
- export declare function splitFinalAnswer(text: string, maxBytes?: number): string[];
@@ -1,46 +0,0 @@
1
- /**
2
- * Split an authoritative final answer into plain-text messages without losing
3
- * any content. Feishu cards have a hard size budget; a long answer must spill
4
- * rather than being silently truncated inside the progress card.
5
- */
6
- const FINAL_ANSWER_CHUNK_BYTES = 20_000;
7
- export function splitFinalAnswer(text, maxBytes = FINAL_ANSWER_CHUNK_BYTES) {
8
- if (!text || maxBytes <= 0)
9
- return text ? [text] : [];
10
- const chunks = [];
11
- let rest = text;
12
- let remainingBytes = Buffer.byteLength(rest, 'utf8');
13
- while (remainingBytes > maxBytes) {
14
- let bytes = 0;
15
- let end = 0;
16
- let lastNewline = 0;
17
- for (const char of rest) {
18
- const charBytes = Buffer.byteLength(char, 'utf8');
19
- if (bytes + charBytes > maxBytes)
20
- break;
21
- bytes += charBytes;
22
- end += char.length;
23
- if (char === '\n')
24
- lastNewline = end;
25
- }
26
- if (end === 0) {
27
- // A caller can choose a byte limit smaller than one codepoint. Keep one
28
- // codepoint per chunk rather than looping forever with a zero-length cut.
29
- const first = Array.from(rest)[0];
30
- chunks.push(first);
31
- rest = rest.slice(first.length);
32
- remainingBytes -= Buffer.byteLength(first, 'utf8');
33
- continue;
34
- }
35
- // Prefer a paragraph/line boundary when it does not waste most of a chunk.
36
- const cut = lastNewline > 0 && lastNewline >= Math.floor(end * 0.6) ? lastNewline : end;
37
- const chunk = rest.slice(0, cut);
38
- chunks.push(chunk);
39
- rest = rest.slice(cut);
40
- remainingBytes -= Buffer.byteLength(chunk, 'utf8');
41
- }
42
- if (rest)
43
- chunks.push(rest);
44
- return chunks;
45
- }
46
- //# sourceMappingURL=final-answer-overflow.js.map