lark-remote 0.5.10 → 0.5.13

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 (126) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/binder.js +7 -1
  5. package/dist/bridge/approval-coordinator.d.ts +1 -0
  6. package/dist/bridge/approval-coordinator.js +12 -24
  7. package/dist/bridge/index.d.ts +7 -0
  8. package/dist/bridge/index.js +89 -230
  9. package/dist/bridge/run-approval.d.ts +15 -0
  10. package/dist/bridge/run-approval.js +41 -0
  11. package/dist/bridge/run-usage.d.ts +46 -0
  12. package/dist/bridge/run-usage.js +96 -0
  13. package/dist/bridge/session-writeback.d.ts +18 -0
  14. package/dist/bridge/session-writeback.js +36 -0
  15. package/dist/card/approval-render.d.ts +14 -0
  16. package/dist/card/approval-render.js +155 -29
  17. package/dist/card/bash-card-session.js +10 -5
  18. package/dist/card/bash-renderer.js +11 -23
  19. package/dist/card/card-budget.js +24 -4
  20. package/dist/card/card-session.d.ts +2 -0
  21. package/dist/card/card-session.js +20 -22
  22. package/dist/card/card-shared.d.ts +4 -4
  23. package/dist/card/card-shared.js +3 -9
  24. package/dist/card/collapsible.js +3 -2
  25. package/dist/card/final-answer-overflow.d.ts +7 -0
  26. package/dist/card/final-answer-overflow.js +46 -0
  27. package/dist/card/run-card-session.js +2 -7
  28. package/dist/card/run-renderer.d.ts +14 -0
  29. package/dist/card/run-renderer.js +150 -10
  30. package/dist/card/run-state.d.ts +12 -1
  31. package/dist/card/run-state.js +73 -34
  32. package/dist/common/text-width.js +5 -0
  33. package/dist/common/tool-summary.js +4 -2
  34. package/dist/config/codex-config.js +5 -3
  35. package/dist/config/index.d.ts +4 -2
  36. package/dist/config/index.js +19 -5
  37. package/dist/inbound/turn-assembler.js +6 -0
  38. package/dist/index.js +15 -391
  39. package/dist/instance-lock.d.ts +9 -1
  40. package/dist/instance-lock.js +42 -7
  41. package/dist/message-handlers.d.ts +26 -0
  42. package/dist/message-handlers.js +275 -0
  43. package/dist/platform/identity.js +4 -3
  44. package/dist/platform/path.js +3 -1
  45. package/dist/platform/terminator-win32.d.ts +13 -1
  46. package/dist/platform/terminator-win32.js +4 -14
  47. package/dist/router/card-action-payload.d.ts +1 -1
  48. package/dist/router/card-action-payload.js +1 -1
  49. package/dist/router/commands/ls.d.ts +9 -0
  50. package/dist/router/commands/ls.js +243 -0
  51. package/dist/router/commands/order.d.ts +12 -0
  52. package/dist/router/commands/order.js +178 -0
  53. package/dist/router/commands/resume.d.ts +47 -0
  54. package/dist/router/commands/resume.js +246 -0
  55. package/dist/router/commands/ws.d.ts +15 -0
  56. package/dist/router/commands/ws.js +189 -0
  57. package/dist/router/config/claude.js +3 -3
  58. package/dist/router/config/codex.js +2 -6
  59. package/dist/router/config/common/turn-idle.d.ts +2 -0
  60. package/dist/router/config/common/turn-idle.js +9 -0
  61. package/dist/router/config/kimi.js +2 -6
  62. package/dist/router/config/opencode.js +2 -6
  63. package/dist/router/index.d.ts +14 -22
  64. package/dist/router/index.js +93 -1133
  65. package/dist/router/paging.d.ts +13 -0
  66. package/dist/router/paging.js +20 -0
  67. package/dist/router/path-validation.d.ts +27 -0
  68. package/dist/router/path-validation.js +45 -0
  69. package/dist/runner/claude/runner.d.ts +3 -4
  70. package/dist/runner/claude/runner.js +4 -5
  71. package/dist/runner/claude/session.js +1 -1
  72. package/dist/runner/codex/app-server/runner.js +12 -0
  73. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  74. package/dist/runner/codex/app-server/translator.js +65 -9
  75. package/dist/runner/common/acp/base-translator.d.ts +15 -8
  76. package/dist/runner/common/acp/base-translator.js +38 -7
  77. package/dist/runner/common/jsonl-stream.js +12 -1
  78. package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
  79. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  80. package/dist/runner/common/jsonrpc/transport.js +7 -1
  81. package/dist/runner/common/spawning-runner.js +10 -4
  82. package/dist/runner/common/turn-started.d.ts +2 -0
  83. package/dist/runner/common/turn-started.js +10 -0
  84. package/dist/runner/dsh/approval.js +16 -3
  85. package/dist/runner/dsh/auth.js +2 -3
  86. package/dist/runner/dsh/client.d.ts +32 -7
  87. package/dist/runner/dsh/client.js +115 -149
  88. package/dist/runner/dsh/daemon.d.ts +0 -2
  89. package/dist/runner/dsh/daemon.js +1 -6
  90. package/dist/runner/dsh/errors.d.ts +5 -0
  91. package/dist/runner/dsh/errors.js +11 -0
  92. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  93. package/dist/runner/dsh/exit-cancel.js +63 -24
  94. package/dist/runner/dsh/home.d.ts +2 -0
  95. package/dist/runner/dsh/home.js +8 -0
  96. package/dist/runner/dsh/inflight.d.ts +0 -2
  97. package/dist/runner/dsh/inflight.js +1 -1
  98. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  99. package/dist/runner/dsh/mux-stream.js +157 -0
  100. package/dist/runner/dsh/runner.d.ts +7 -0
  101. package/dist/runner/dsh/runner.js +42 -5
  102. package/dist/runner/dsh/translator.d.ts +0 -10
  103. package/dist/runner/dsh/translator.js +1 -1
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.js +42 -5
  106. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  107. package/dist/runner/kimi/acp/translator.js +11 -12
  108. package/dist/runner/opencode/acp/translator.js +1 -12
  109. package/dist/runner/pi/rpc/runner.js +33 -5
  110. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  111. package/dist/runner/pi/rpc/translator.js +2 -7
  112. package/dist/runner/registry.d.ts +0 -19
  113. package/dist/runner/registry.js +0 -32
  114. package/dist/runner/resolve-agent-choices.js +5 -1
  115. package/dist/runner/sync-agent-choices.js +4 -1
  116. package/dist/runner/types.d.ts +17 -1
  117. package/dist/session/claude/session-index.js +13 -4
  118. package/dist/session/codex/rollout-reader.js +51 -26
  119. package/dist/session/common/usage-accumulator.d.ts +10 -1
  120. package/dist/session/common/usage-accumulator.js +10 -2
  121. package/dist/session/dsh/sessions.js +29 -9
  122. package/dist/session/kimi/sessions.js +11 -7
  123. package/dist/session/opencode/sessions.js +15 -7
  124. package/dist/session/pi/sessions.js +3 -2
  125. package/dist/session/session-store.js +4 -3
  126. package/package.json +1 -1
@@ -224,7 +224,12 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
224
224
  dropOldestPanels(panelCount - maxEventPanels);
225
225
  reasons.push('event_count_limited');
226
226
  }
227
- if (size() <= CARD_BUDGET_BYTES) {
227
+ // 元素数预算必须与字节预算同查(含 hint 预留位):字节路径的返回条件此前
228
+ // 只看字节——元素超限但卡内无 collapsible_panel 时(如 95 个小 div),
229
+ // 阶段1-4 全是空操作,超限卡原样直通(实测 wasTruncated:false、95 元素
230
+ // > 90 → 飞书 11310 整卡报废,正是文件头注释记载的 /ws、/order 故障形态)。
231
+ const elementsWithinBudgetWithHint = () => countCardElements(truncated) + (omitted > 0 ? 1 : 0) <= MAX_CARD_ELEMENTS;
232
+ if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
228
233
  insertOmittedHint(elements, omitted, truncationHint);
229
234
  return done(modifications > 0 || omitted > 0);
230
235
  }
@@ -235,13 +240,12 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
235
240
  if (omitted > 0) {
236
241
  reasons.push('panels_dropped');
237
242
  }
238
- if (size() <= CARD_BUDGET_BYTES) {
243
+ if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
239
244
  insertOmittedHint(elements, omitted, truncationHint);
240
245
  return done(true);
241
246
  }
242
247
  // ========== 阶段4:截断剩余顶层 div 文本 ==========
243
248
  // 骨架元素(header/usage 等)自身超限的情况(如超大 displayTitle)。
244
- insertOmittedHint(elements, omitted, truncationHint);
245
249
  let textTruncations = 0;
246
250
  for (const el of elements) {
247
251
  if (el.tag === 'div' && el.text?.content) {
@@ -255,9 +259,25 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
255
259
  if (textTruncations > 0) {
256
260
  reasons.push('text_truncated');
257
261
  }
258
- if (size() <= CARD_BUDGET_BYTES) {
262
+ if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
263
+ insertOmittedHint(elements, omitted, truncationHint);
259
264
  return done(true);
260
265
  }
266
+ // ========== 阶段4.5:元素数兜底(无面板可删的元素超限) ==========
267
+ // 阶段1-4 只会裁内容/删面板;元素超限且无面板时在此按序丢弃最旧的顶层
268
+ // 元素直到达标(与 table/panel 的「删最旧、留最新」口径一致,尾部操作
269
+ // 按钮保留)。留 ≥3 个元素兜底,仍不达标则交给阶段5 的最小卡。
270
+ if (countCardElements(truncated) > MAX_CARD_ELEMENTS) {
271
+ while (countCardElements(truncated) > MAX_CARD_ELEMENTS && elements.length > 3) {
272
+ elements.shift();
273
+ omitted++;
274
+ }
275
+ reasons.push('element_budget_elements_dropped');
276
+ if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
277
+ insertOmittedHint(elements, omitted, truncationHint);
278
+ return done(true);
279
+ }
280
+ }
261
281
  // ========== 阶段5:最终兜底(原 extreme_fallback) ==========
262
282
  const minimalCard = cardWithHeader({
263
283
  template: 'orange',
@@ -117,5 +117,7 @@ export declare abstract class CardSession<S, RO = unknown> {
117
117
  * connector.updateCard if the controller is unavailable or fails.
118
118
  */
119
119
  protected updateCard(): Promise<void>;
120
+ private waitForControllerReady;
121
+ protected prepareTerminalUpdate(): Promise<void>;
120
122
  protected release(): void;
121
123
  }
@@ -138,17 +138,7 @@ export class CardSession {
138
138
  // 二次调用也套 timeout 保护:若 controllerReady 永不 settle(首次超时失败),
139
139
  // 不能永久挂起。复用同样的 startTimeoutMs 超时。streamOutcome 已存在,
140
140
  // 不重复创建 stream。
141
- let timer;
142
- const timeout = new Promise((_, reject) => {
143
- timer = setTimeout(() => reject(new Error(`${this.errorPrefix} stream start timeout`)), this.startTimeoutMs);
144
- });
145
- try {
146
- await Promise.race([this.controllerReady, timeout]);
147
- }
148
- finally {
149
- if (timer)
150
- clearTimeout(timer);
151
- }
141
+ await this.waitForControllerReady();
152
142
  return;
153
143
  }
154
144
  // NOTE: the two renderCard calls below are NOT redundant. The streamCard
@@ -177,17 +167,7 @@ export class CardSession {
177
167
  this.rejectController(error);
178
168
  return { ok: false, error };
179
169
  });
180
- let timer;
181
- const timeout = new Promise((_, reject) => {
182
- timer = setTimeout(() => reject(new Error(`${this.errorPrefix} stream start timeout`)), this.startTimeoutMs);
183
- });
184
- try {
185
- await Promise.race([this.controllerReady, timeout]);
186
- }
187
- finally {
188
- if (timer)
189
- clearTimeout(timer);
190
- }
170
+ await this.waitForControllerReady();
191
171
  }
192
172
  async settle() {
193
173
  this.release();
@@ -247,6 +227,24 @@ export class CardSession {
247
227
  }
248
228
  }
249
229
  }
230
+ async waitForControllerReady() {
231
+ let timer;
232
+ const timeout = new Promise((_, reject) => {
233
+ timer = setTimeout(() => reject(new Error(`${this.errorPrefix} stream start timeout`)), this.startTimeoutMs);
234
+ });
235
+ try {
236
+ await Promise.race([this.controllerReady, timeout]);
237
+ }
238
+ finally {
239
+ if (timer)
240
+ clearTimeout(timer);
241
+ }
242
+ }
243
+ async prepareTerminalUpdate() {
244
+ this.cancelPendingFlush();
245
+ if (this.flushP)
246
+ await this.flushP;
247
+ }
250
248
  release() {
251
249
  if (this.released)
252
250
  return;
@@ -1,10 +1,10 @@
1
- import { type AgentKind } from '../runner/index.js';
1
+ import { type AgentKind } from '../runner/types.js';
2
2
  /**
3
3
  * Display name for an agent kind in the run card header.
4
4
  *
5
- * Reads from the global `AgentRegistry` (set up in `index.ts` at startup)
6
- * when available. Falls back to a hardcoded map for callers that don't
7
- * wire up the registry (notably unit tests of pure render helpers).
5
+ * Display names are static UI labels, so the constant is the single source.
6
+ * Keeping a second mutable registry here made render helpers depend on startup
7
+ * order and forced tests to reset global state.
8
8
  */
9
9
  export declare function agentDisplayName(kind: string): string;
10
10
  export declare function terminalToLabel(terminal: string): string;
@@ -1,18 +1,12 @@
1
- import { AgentRegistry } from '../runner/index.js';
2
1
  import { AGENT_DISPLAY_NAMES } from '../runner/types.js';
3
2
  /**
4
3
  * Display name for an agent kind in the run card header.
5
4
  *
6
- * Reads from the global `AgentRegistry` (set up in `index.ts` at startup)
7
- * when available. Falls back to a hardcoded map for callers that don't
8
- * wire up the registry (notably unit tests of pure render helpers).
5
+ * Display names are static UI labels, so the constant is the single source.
6
+ * Keeping a second mutable registry here made render helpers depend on startup
7
+ * order and forced tests to reset global state.
9
8
  */
10
9
  export function agentDisplayName(kind) {
11
- const globalReg = AgentRegistry.getGlobalInstance();
12
- if (globalReg) {
13
- return globalReg.getDisplayName(kind);
14
- }
15
- // Fallback for render helpers that run without the global registry.
16
10
  return AGENT_DISPLAY_NAMES[kind] ?? kind;
17
11
  }
18
12
  const TERMINAL_LABELS = {
@@ -76,8 +76,9 @@ export function markdownDiv(content, textSize) {
76
76
  * to be interpreted as a literal. Escape it to preserve the raw backslash.
77
77
  *
78
78
  * Note: Square brackets [] and pipe | are NOT escaped -- they are valid markdown
79
- * (links, tables) and escaping them breaks link rendering. Verified safe via
80
- * tests/escape-bracket-experiment.test.ts: unescaped []| does not trigger 11311.
79
+ * (links, tables) and escaping them breaks link rendering. Verified by the live
80
+ * regression test tests/feishu-live/escape-bracket-regression.test.ts: unescaped
81
+ * []| does not trigger 11311.
81
82
  */
82
83
  function escapeMarkdown(content) {
83
84
  if (!content)
@@ -0,0 +1,7 @@
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
+ export declare const FINAL_ANSWER_CHUNK_BYTES = 20000;
7
+ export declare function splitFinalAnswer(text: string, maxBytes?: number): string[];
@@ -0,0 +1,46 @@
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
+ export 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
@@ -46,13 +46,8 @@ export class RunCardSession extends CardSession {
46
46
  }
47
47
  async finish(terminal, meta = {}) {
48
48
  // 终态必须即时显示(spec):取消合批窗口,不等延迟 flush 调度。
49
- this.cancelPendingFlush();
50
- // 若有 in-flight flush 正在 await updateCard,必须等它完成后再发终态 patch。
51
- // 否则两条并发 controller.update 无 FIFO 保证,in-flight 的 pre-terminal
52
- // patch 可能在终态 patch 之后 resolve → 用户看到"思考中"终帧。
53
- // await 保证顺序:pre-terminal 先落地 → terminal 后落地 → terminal 胜出。
54
- if (this.flushP)
55
- await this.flushP;
49
+ // 终态 patch 必须排在已开始的 pre-terminal flush 之后。
50
+ await this.prepareTerminalUpdate();
56
51
  this.state = finishRun(this.state, terminal, meta);
57
52
  await this.updateCard();
58
53
  this.release();
@@ -20,6 +20,12 @@ export interface RunCardRenderOptions {
20
20
  */
21
21
  sessionFallbackAvailable?: boolean;
22
22
  }
23
+ export interface RunCardRenderOutcome {
24
+ card: object;
25
+ finalAnswerStatus: 'none' | 'full' | 'overflow';
26
+ /** Full text to deliver outside the card when finalAnswerStatus is overflow. */
27
+ finalAnswerText?: string;
28
+ }
23
29
  type BlockGroup = {
24
30
  kind: 'thinking';
25
31
  content: string;
@@ -73,4 +79,12 @@ export declare function estimateCardBytes(state: RunState, options?: RunCardRend
73
79
  * CardKit 2.0 renderer - simplified for streaming (no tabs)
74
80
  */
75
81
  export declare function renderRunCard(state: RunState, options?: RunCardRenderOptions): object;
82
+ /**
83
+ * Render a run card with metadata the bridge needs to deliver an answer that
84
+ * cannot fit in one Feishu card. `renderRunCard` remains the card-only API used
85
+ * by streaming sessions.
86
+ */
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_";
76
90
  export {};
@@ -9,7 +9,7 @@ import { displayWidth, truncateToDisplayWidth } from '../common/text-width.js';
9
9
  import { isContentFilterFailure } from '../common/turn-failure.js';
10
10
  import { formatClock, formatTimestamp } from './time.js';
11
11
  import { formatUsageStats } from '../router/usage-format.js';
12
- import { renderApprovalArea } from './approval-render.js';
12
+ import { renderApprovalArea, renderApprovalAreaCompact } from './approval-render.js';
13
13
  const REASONING_BYTES = 4_500;
14
14
  const TEXT_BYTES = 10_000;
15
15
  const DEGRADED_THINKING_KEEP = 2;
@@ -448,6 +448,24 @@ function approvalArea(state) {
448
448
  }
449
449
  return elements;
450
450
  }
451
+ /**
452
+ * 紧凑审批区(skeleton/truncatedSkeleton 兜底专用):完整审批区不受估算器
453
+ * 管辖、不参与 degraded/extreme 分层裁剪,pathological 输入(超长 reason、
454
+ * 题数×选项爆元素数)会让所有兜底层一起超限——超限卡被飞书整卡拒收(11310),
455
+ * 审批彻底不可见,run 挂到审批超时/看门狗。兜底层换用紧凑形态(固定小体积、
456
+ * 保留决策/跳过按钮),结构性保证审批入口在任何降级档都活着。
457
+ */
458
+ function approvalAreaCompact(state) {
459
+ const elements = [];
460
+ for (const slot of state.approvals ?? []) {
461
+ elements.push(...renderApprovalAreaCompact(slot.view, {
462
+ expired: slot.expired,
463
+ runId: state.runId,
464
+ terminal: state.terminal,
465
+ }));
466
+ }
467
+ return elements;
468
+ }
451
469
  /** 底部操作行之前的内容区提示:被 MAX_BLOCKS 截掉的早期块计数(信息保真)。 */
452
470
  function omittedBlocksHint(state) {
453
471
  return state.omittedBlocks !== undefined && state.omittedBlocks > 0
@@ -654,11 +672,12 @@ function elapsedLabel(state) {
654
672
  return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
655
673
  return `${Math.floor(hours / 24)}d${String(hours % 24).padStart(2, '0')}h`;
656
674
  }
657
- /** token 数量的紧凑写法:`950` / `12K` / `1.2M`。 */
675
+ /** token 数量的紧凑写法:`950` / `12K` / `1.0M`。 */
658
676
  function formatTokens(tokens) {
659
677
  if (tokens < 1000)
660
678
  return String(tokens);
661
- if (tokens < 1_000_000)
679
+ // 千位边界取 M 档:999_500 起四舍五入会得到 "1000K",直接按 M 档展示。
680
+ if (tokens < 999_500)
662
681
  return `${Math.round(tokens / 1000)}K`;
663
682
  return `${(tokens / 1_000_000).toFixed(1)}M`;
664
683
  }
@@ -848,6 +867,20 @@ function measurePanelBytes(title, expanded, border, content) {
848
867
  * CardKit 2.0 renderer - simplified for streaming (no tabs)
849
868
  */
850
869
  export function renderRunCard(state, options = {}) {
870
+ return renderRunCardDetailed(state, options).card;
871
+ }
872
+ /**
873
+ * Render a run card with metadata the bridge needs to deliver an answer that
874
+ * cannot fit in one Feishu card. `renderRunCard` remains the card-only API used
875
+ * by streaming sessions.
876
+ */
877
+ export function renderRunCardDetailed(state, options = {}) {
878
+ if (state.finalAnswer?.content.trim()) {
879
+ return buildFinalAnswerPriorityCard(state, options);
880
+ }
881
+ return { card: renderLegacyRunCard(state, options), finalAnswerStatus: 'none' };
882
+ }
883
+ function renderLegacyRunCard(state, options) {
851
884
  // 先估后建:用廉价估算预测是否超 28KB 预算,明显超预算直接跳过完整卡构建走
852
885
  // degraded。groups/prepared 只算一次,测量与正式渲染共用同一份截断结果,
853
886
  // 避免影子测量 + 正式渲染对每个超限块双倍截断。
@@ -885,26 +918,133 @@ export function renderRunCard(state, options = {}) {
885
918
  return extremeCard;
886
919
  }
887
920
  const skeletonCard = assembleRunCard(state, options, buildSkeletonElements(state, options));
888
- // skeleton 卡是静态结构,理论上必 ≤28KB;保留兜底断言以防未来结构膨胀
889
- if (Buffer.byteLength(JSON.stringify(skeletonCard), 'utf8') <= CARD_BUDGET_BYTES) {
921
+ // skeleton 卡是静态结构,理论上必 ≤28KB;但审批区/summary 携带 agent 产出
922
+ // 文本,仍可能把任一维度顶爆(此前只查字节——题数×选项的元素数超限会漏网)。
923
+ // 保留兜底断言以防未来结构膨胀;超限则降级到 truncatedSkeleton。
924
+ if (withinRunCardBudget(skeletonCard)) {
890
925
  return skeletonCard;
891
926
  }
892
- // 理论上不可达:skeleton 已是静态最小结构。若仍超限(errorMsg 等无界文本
893
- // 灌进 summary),硬截断只丢 summary——审批入口与 stop/新会话按钮是红线,
894
- // 任何一级兜底都不能把它们截掉(丢了用户就再也停不下这个 run)。
927
+ // 理论上不可达:skeleton 已是静态最小结构。若仍超限(超长 reason、题数×
928
+ // 选项爆元素数等审批区输入),硬截断只丢 summary 与完整审批区——审批入口
929
+ // (紧凑形态:标题 + 摘要 + 决策/跳过按钮)与 stop/新会话按钮是红线,任何
930
+ // 一级兜底都不能把它们截掉(丢了用户就再也停不下这个 run / 答不了审批)。
895
931
  const truncatedSkeleton = {
896
932
  ...skeletonCard,
897
933
  body: {
898
934
  elements: [
899
935
  statusRow(state),
900
936
  markdownDiv('_⚠️ 输出过大_'),
901
- ...approvalArea(state),
937
+ ...approvalAreaCompact(state),
902
938
  ...actionRow(state, options),
903
939
  ],
904
940
  },
905
941
  };
906
942
  return truncatedSkeleton;
907
943
  }
944
+ /** Exact-match marker used in the card before the answer is sent separately. */
945
+ export const FINAL_ANSWER_OVERFLOW_MARKER = '_最终回答较长,完整内容已随后发送_';
946
+ /**
947
+ * Build a card around the authoritative final answer.
948
+ *
949
+ * The answer is measured first with the fixed controls. Progress groups are
950
+ * added newest-first only while the whole card still fits. If the answer
951
+ * cannot fit even without progress/summary, the card carries an explicit
952
+ * marker and the bridge sends the complete answer separately.
953
+ */
954
+ function buildFinalAnswerPriorityCard(state, options) {
955
+ const finalAnswer = state.finalAnswer;
956
+ const groups = groupBlocks(state.blocks);
957
+ const prepared = prepareGroupContent(groups);
958
+ const safeAnswer = truncateMarkdownTables(finalAnswer.content);
959
+ const requiresOverflow = safeAnswer !== finalAnswer.content;
960
+ const ts = formatTimestamp(finalAnswer.timestamp);
961
+ const answerElements = renderTextBlock(safeAnswer, ts, state.terminal !== 'running');
962
+ const fixedStart = [statusRow(state), ...omittedBlocksHint(state)];
963
+ const summary = buildSummaryContent(state, { includeNotices: false });
964
+ const controls = [...actionRow(state, options), ...approvalArea(state)];
965
+ const buildWithProgress = (start, includeSummary) => {
966
+ const renderedProgress = buildAnswerProgressContent(state, prepared, groups.slice(start));
967
+ const progress = start < groups.length
968
+ ? [markdownDiv(`_💡 已省略 ${start} 个早期步骤_`), ...renderedProgress]
969
+ : renderedProgress;
970
+ return assembleRunCard(state, options, [
971
+ ...fixedStart,
972
+ ...progress,
973
+ ...(requiresOverflow ? [markdownDiv(FINAL_ANSWER_OVERFLOW_MARKER)] : []),
974
+ ...answerElements,
975
+ ...(includeSummary ? summary : []),
976
+ ...controls,
977
+ ]);
978
+ };
979
+ let best = buildWithProgress(groups.length, true);
980
+ if (withinRunCardBudget(best)) {
981
+ for (let start = groups.length - 1; start >= 0; start--) {
982
+ const candidate = buildWithProgress(start, true);
983
+ if (!withinRunCardBudget(candidate))
984
+ break;
985
+ best = candidate;
986
+ }
987
+ return {
988
+ card: best,
989
+ finalAnswerStatus: requiresOverflow ? 'overflow' : 'full',
990
+ ...(requiresOverflow ? { finalAnswerText: finalAnswer.content } : {}),
991
+ };
992
+ }
993
+ // The answer outranks the usage summary. Keep the answer and controls first,
994
+ // then retry without the summary before declaring a physical overflow.
995
+ const withoutSummary = buildWithProgress(groups.length, false);
996
+ if (withinRunCardBudget(withoutSummary)) {
997
+ return {
998
+ card: withoutSummary,
999
+ finalAnswerStatus: requiresOverflow ? 'overflow' : 'full',
1000
+ ...(requiresOverflow ? { finalAnswerText: finalAnswer.content } : {}),
1001
+ };
1002
+ }
1003
+ const overflowCard = assembleRunCard(state, options, [
1004
+ ...fixedStart,
1005
+ markdownDiv(FINAL_ANSWER_OVERFLOW_MARKER),
1006
+ ...controls,
1007
+ ]);
1008
+ return {
1009
+ card: overflowCard,
1010
+ finalAnswerStatus: 'overflow',
1011
+ finalAnswerText: finalAnswer.content,
1012
+ };
1013
+ }
1014
+ /** Render a contiguous progress suffix for the answer-priority card. */
1015
+ function buildAnswerProgressContent(state, prepared, visible) {
1016
+ const elements = [];
1017
+ const outputMax = toolOutputBudget(state);
1018
+ const contentItems = mergeNoticesIntoContent(visible, state.notices);
1019
+ for (const item of contentItems) {
1020
+ if (item.kind === 'notice') {
1021
+ elements.push(renderNoticeRow(item.notice));
1022
+ continue;
1023
+ }
1024
+ const group = item;
1025
+ if (group.kind === 'tool') {
1026
+ elements.push(renderTool(group.tool, outputMax));
1027
+ continue;
1028
+ }
1029
+ if (group.kind === 'text') {
1030
+ const content = prepared.get(group) ?? '';
1031
+ const ts = formatTimestamp(group.timestamp);
1032
+ elements.push(...renderTextBlock(content, ts, state.terminal !== 'running'));
1033
+ continue;
1034
+ }
1035
+ const spec = describePanelGroup(group, prepared);
1036
+ if (!spec)
1037
+ continue;
1038
+ elements.push(collapsibleMarkdownPanel({
1039
+ title: spec.title,
1040
+ expanded: spec.expanded,
1041
+ border: spec.border,
1042
+ content: spec.content,
1043
+ textSize: 'notation',
1044
+ }));
1045
+ }
1046
+ return elements;
1047
+ }
908
1048
  /**
909
1049
  * Run 卡片的双预算检查:UTF-8 字节 ≤ 28KB **且** 元素数 ≤ `MAX_CARD_ELEMENTS`。
910
1050
  *
@@ -1015,7 +1155,7 @@ function buildSummaryContent(state, options = {}) {
1015
1155
  }
1016
1156
  else {
1017
1157
  const result = state.resultSubtype ?? 'success';
1018
- const hasContent = state.blocks.length > 0;
1158
+ const hasContent = state.blocks.length > 0 || !!state.finalAnswer?.content.trim();
1019
1159
  // 压缩(operationKind='compaction')不会返回 agent 正文——引擎只压缩上下文
1020
1160
  // 窗口,空内容属正常,不能沿用普通 run 的「(未返回内容)」异常信号
1021
1161
  // (2026-09 用户反馈)。所有会压缩的 agent(codex/claude/kimi/opencode/pi)
@@ -13,7 +13,7 @@ export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interru
13
13
  * 禁止各自再抄一份字面量——三处漂移过一次(goal_continuing 只进白名单不进
14
14
  * 按钮判据),新增终态时必漏一处的风险是结构性的。
15
15
  */
16
- export declare const RUN_TERMINAL_STATES: readonly ["done", "error", "interrupted", "idle_timeout", "goal_continuing"];
16
+ declare const RUN_TERMINAL_STATES: readonly ["done", "error", "interrupted", "idle_timeout", "goal_continuing"];
17
17
  export type RunFinalTerminal = (typeof RUN_TERMINAL_STATES)[number];
18
18
  /** 该状态是否已收尾(非 running/finalizing)。 */
19
19
  export declare function isRunTerminal(terminal: RunTerminal): terminal is RunFinalTerminal;
@@ -85,6 +85,12 @@ export interface RunNotice {
85
85
  text: string;
86
86
  timestamp?: string;
87
87
  }
88
+ export interface FinalAnswerState {
89
+ content: string;
90
+ timestamp?: string;
91
+ complete: boolean;
92
+ source: 'protocol' | 'inferred';
93
+ }
88
94
  export interface RunState {
89
95
  runId: string;
90
96
  terminal: RunTerminal;
@@ -94,6 +100,11 @@ export interface RunState {
94
100
  /** 本 run 结束时间(epoch ms):终态后耗时定格,不再随渲染时刻增长。 */
95
101
  finishedAt?: number;
96
102
  blocks: RunBlock[];
103
+ /**
104
+ * The answer the user is waiting for. Kept outside `blocks` so progress
105
+ * traffic cannot evict or consume its display budget.
106
+ */
107
+ finalAnswer?: FinalAnswerState;
97
108
  sessionId?: string;
98
109
  resultSubtype?: 'success' | 'error' | 'interrupted';
99
110
  /** interrupted 终态的中断来源;undefined = 未归类(渲染默认文案)。 */