lark-remote 0.5.12 → 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 (95) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/bridge/approval-coordinator.d.ts +1 -0
  5. package/dist/bridge/approval-coordinator.js +12 -24
  6. package/dist/bridge/index.d.ts +7 -0
  7. package/dist/bridge/index.js +77 -227
  8. package/dist/bridge/run-approval.d.ts +15 -0
  9. package/dist/bridge/run-approval.js +41 -0
  10. package/dist/bridge/run-usage.d.ts +46 -0
  11. package/dist/bridge/run-usage.js +96 -0
  12. package/dist/bridge/session-writeback.d.ts +18 -0
  13. package/dist/bridge/session-writeback.js +36 -0
  14. package/dist/card/bash-card-session.js +2 -5
  15. package/dist/card/card-session.d.ts +2 -0
  16. package/dist/card/card-session.js +20 -22
  17. package/dist/card/card-shared.d.ts +4 -4
  18. package/dist/card/card-shared.js +3 -9
  19. package/dist/card/collapsible.js +3 -2
  20. package/dist/card/final-answer-overflow.d.ts +7 -0
  21. package/dist/card/final-answer-overflow.js +46 -0
  22. package/dist/card/run-card-session.js +2 -7
  23. package/dist/card/run-renderer.d.ts +14 -0
  24. package/dist/card/run-renderer.js +119 -1
  25. package/dist/card/run-state.d.ts +12 -1
  26. package/dist/card/run-state.js +73 -34
  27. package/dist/config/codex-config.js +4 -4
  28. package/dist/config/index.d.ts +4 -2
  29. package/dist/config/index.js +6 -4
  30. package/dist/inbound/turn-assembler.js +6 -0
  31. package/dist/index.js +15 -409
  32. package/dist/message-handlers.d.ts +26 -0
  33. package/dist/message-handlers.js +275 -0
  34. package/dist/router/card-action-payload.d.ts +1 -1
  35. package/dist/router/card-action-payload.js +1 -1
  36. package/dist/router/commands/ls.d.ts +9 -0
  37. package/dist/router/commands/ls.js +243 -0
  38. package/dist/router/commands/order.d.ts +12 -0
  39. package/dist/router/commands/order.js +178 -0
  40. package/dist/router/commands/resume.d.ts +47 -0
  41. package/dist/router/commands/resume.js +246 -0
  42. package/dist/router/commands/ws.d.ts +15 -0
  43. package/dist/router/commands/ws.js +189 -0
  44. package/dist/router/config/claude.js +3 -3
  45. package/dist/router/config/codex.js +2 -6
  46. package/dist/router/config/common/turn-idle.d.ts +2 -0
  47. package/dist/router/config/common/turn-idle.js +9 -0
  48. package/dist/router/config/kimi.js +2 -6
  49. package/dist/router/config/opencode.js +2 -6
  50. package/dist/router/index.d.ts +14 -22
  51. package/dist/router/index.js +70 -1152
  52. package/dist/router/paging.d.ts +13 -0
  53. package/dist/router/paging.js +20 -0
  54. package/dist/router/path-validation.d.ts +27 -0
  55. package/dist/router/path-validation.js +45 -0
  56. package/dist/runner/claude/runner.d.ts +3 -4
  57. package/dist/runner/claude/runner.js +4 -5
  58. package/dist/runner/claude/session.js +1 -1
  59. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  60. package/dist/runner/codex/app-server/translator.js +65 -9
  61. package/dist/runner/common/acp/base-translator.d.ts +3 -8
  62. package/dist/runner/common/acp/base-translator.js +16 -7
  63. package/dist/runner/common/spawning-runner.js +10 -4
  64. package/dist/runner/common/turn-started.d.ts +2 -0
  65. package/dist/runner/common/turn-started.js +10 -0
  66. package/dist/runner/dsh/auth.js +2 -3
  67. package/dist/runner/dsh/client.d.ts +1 -6
  68. package/dist/runner/dsh/client.js +11 -165
  69. package/dist/runner/dsh/daemon.d.ts +0 -2
  70. package/dist/runner/dsh/daemon.js +1 -6
  71. package/dist/runner/dsh/errors.d.ts +5 -0
  72. package/dist/runner/dsh/errors.js +11 -0
  73. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  74. package/dist/runner/dsh/exit-cancel.js +1 -1
  75. package/dist/runner/dsh/home.d.ts +2 -0
  76. package/dist/runner/dsh/home.js +8 -0
  77. package/dist/runner/dsh/inflight.d.ts +0 -2
  78. package/dist/runner/dsh/inflight.js +1 -1
  79. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  80. package/dist/runner/dsh/mux-stream.js +157 -0
  81. package/dist/runner/dsh/translator.d.ts +0 -10
  82. package/dist/runner/dsh/translator.js +1 -1
  83. package/dist/runner/index.d.ts +1 -1
  84. package/dist/runner/kimi/acp/translator.js +1 -12
  85. package/dist/runner/opencode/acp/translator.js +1 -12
  86. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  87. package/dist/runner/pi/rpc/translator.js +2 -7
  88. package/dist/runner/registry.d.ts +0 -19
  89. package/dist/runner/registry.js +0 -32
  90. package/dist/runner/types.d.ts +17 -1
  91. package/dist/session/claude/session-index.js +4 -2
  92. package/dist/session/kimi/sessions.js +2 -2
  93. package/dist/session/opencode/sessions.js +2 -1
  94. package/dist/session/pi/sessions.js +3 -2
  95. package/package.json +1 -1
@@ -0,0 +1,96 @@
1
+ import { normalizeResultUsage } from '../runner/common/result-usage.js';
2
+ export class RunUsageCollector {
3
+ contextLength;
4
+ finalContextLength;
5
+ finalContextLimit;
6
+ finalCompactCount;
7
+ finalCacheReadTokens;
8
+ finalCacheCreationTokens;
9
+ finalTotalTokens;
10
+ liveInput;
11
+ liveOutput;
12
+ liveContextLimit;
13
+ finalInputTokens;
14
+ finalOutputTokens;
15
+ finalCumulativeTotalTokens;
16
+ finalCumulativeInputTokens;
17
+ finalCumulativeOutputTokens;
18
+ finalCumulativeCacheReadTokens;
19
+ finalCumulativeCacheCreationTokens;
20
+ finalReasoningTokens;
21
+ resultNotSuccess = false;
22
+ get liveInputTokens() {
23
+ return this.liveInput;
24
+ }
25
+ get liveOutputTokens() {
26
+ return this.liveOutput;
27
+ }
28
+ observeCompaction(postTokens) {
29
+ this.contextLength = postTokens;
30
+ }
31
+ observeResult(event) {
32
+ this.resultNotSuccess = event.subtype !== 'success';
33
+ if (!event.usage)
34
+ return;
35
+ const usage = normalizeResultUsage(event.usage);
36
+ if (!this.contextLength)
37
+ this.contextLength = usage.contextLength;
38
+ this.liveInput = usage.inputTokens;
39
+ this.liveOutput = usage.outputTokens;
40
+ this.liveContextLimit = usage.contextLimit;
41
+ if (usage.cacheReadTokens !== undefined)
42
+ this.finalCacheReadTokens = usage.cacheReadTokens;
43
+ if (usage.cacheCreationTokens !== undefined) {
44
+ this.finalCacheCreationTokens = usage.cacheCreationTokens;
45
+ }
46
+ if (usage.totalTokens !== undefined)
47
+ this.finalTotalTokens = usage.totalTokens;
48
+ if (usage.reasoningTokens !== undefined)
49
+ this.finalReasoningTokens = usage.reasoningTokens;
50
+ }
51
+ finalize(finalUsage, authority) {
52
+ this.finalContextLength = finalUsage?.contextLength ?? this.contextLength;
53
+ this.finalContextLimit = finalUsage?.contextLimit ?? this.liveContextLimit;
54
+ this.finalCompactCount = finalUsage?.compactCount;
55
+ this.finalCumulativeTotalTokens = finalUsage?.cumulativeTotalTokens;
56
+ this.finalCumulativeInputTokens = finalUsage?.cumulativeInputTokens;
57
+ this.finalCumulativeOutputTokens = finalUsage?.cumulativeOutputTokens;
58
+ this.finalCumulativeCacheReadTokens = finalUsage?.cumulativeCacheReadTokens;
59
+ this.finalCumulativeCacheCreationTokens = finalUsage?.cumulativeCacheCreationTokens;
60
+ const useLiveFlowScope = authority !== 'jsonl' && this.liveInput !== undefined && this.liveOutput !== undefined;
61
+ if (useLiveFlowScope) {
62
+ this.finalInputTokens = this.liveInput;
63
+ this.finalOutputTokens = this.liveOutput;
64
+ return;
65
+ }
66
+ this.finalInputTokens = finalUsage?.inputTokens ?? this.liveInput;
67
+ this.finalOutputTokens = finalUsage?.outputTokens ?? this.liveOutput;
68
+ this.finalCacheReadTokens = finalUsage?.cacheReadTokens ?? this.finalCacheReadTokens;
69
+ this.finalCacheCreationTokens =
70
+ finalUsage?.cacheCreationTokens ?? this.finalCacheCreationTokens;
71
+ this.finalTotalTokens = finalUsage?.totalTokens ?? this.finalTotalTokens;
72
+ }
73
+ meta() {
74
+ return {
75
+ contextLength: this.finalContextLength,
76
+ contextLimit: this.finalContextLimit,
77
+ compactCount: this.finalCompactCount,
78
+ cacheReadTokens: this.finalCacheReadTokens,
79
+ cacheCreationTokens: this.finalCacheCreationTokens,
80
+ totalTokens: this.finalTotalTokens,
81
+ inputTokens: this.finalInputTokens,
82
+ outputTokens: this.finalOutputTokens,
83
+ cumulativeTotalTokens: this.finalCumulativeTotalTokens,
84
+ cumulativeInputTokens: this.finalCumulativeInputTokens,
85
+ cumulativeOutputTokens: this.finalCumulativeOutputTokens,
86
+ cumulativeCacheReadTokens: this.finalCumulativeCacheReadTokens,
87
+ cumulativeCacheCreationTokens: this.finalCumulativeCacheCreationTokens,
88
+ reasoningTokens: this.finalReasoningTokens,
89
+ };
90
+ }
91
+ logSummary(runId) {
92
+ const meta = this.meta();
93
+ return `[lark-remote] final usage runId=${runId} contextLength=${meta.contextLength} contextLimit=${meta.contextLimit ?? 'undefined'} compactCount=${meta.compactCount ?? 'undefined'} cacheRead=${meta.cacheReadTokens ?? 'undefined'} cacheCreate=${meta.cacheCreationTokens ?? 'undefined'}`;
94
+ }
95
+ }
96
+ //# sourceMappingURL=run-usage.js.map
@@ -0,0 +1,18 @@
1
+ import type { AgentKind, TurnStartedEvent } from '../runner/index.js';
2
+ import type { SessionStore } from '../session/index.js';
3
+ interface InitEvent {
4
+ session_id: string;
5
+ cwd?: string;
6
+ }
7
+ interface WritebackContext {
8
+ sessionStore: SessionStore;
9
+ userId: string;
10
+ agentKind: AgentKind;
11
+ cwd: string;
12
+ sessionEpochAtStart: number;
13
+ runId: string;
14
+ }
15
+ export declare function writeBackInitSession(ctx: WritebackContext, event: InitEvent): void;
16
+ export declare function writeBackTurnStarted(ctx: WritebackContext, event: TurnStartedEvent): boolean;
17
+ export declare function writeBackResultSession(ctx: WritebackContext, sessionId: string | undefined): void;
18
+ export {};
@@ -0,0 +1,36 @@
1
+ import { getLogger } from '../logger/index.js';
2
+ function pointerMoved(ctx) {
3
+ return ctx.sessionStore.getSessionEpoch(ctx.userId, ctx.agentKind) !== ctx.sessionEpochAtStart;
4
+ }
5
+ export function writeBackInitSession(ctx, event) {
6
+ if (pointerMoved(ctx)) {
7
+ getLogger().info(`[lark-remote] system.init write-back skipped: session pointer moved since run start runId=${ctx.runId} sessionId=${event.session_id}`);
8
+ return;
9
+ }
10
+ const eventCwd = typeof event.cwd === 'string' ? event.cwd : '';
11
+ const hasCwd = !!ctx.sessionStore.getCwd(ctx.userId);
12
+ const realCwd = eventCwd.length > 0 ? eventCwd : ctx.cwd;
13
+ if (hasCwd && eventCwd && eventCwd !== ctx.cwd) {
14
+ getLogger().info(`[lark-remote] system.init: cwd=${ctx.cwd}, session cwd=${eventCwd}`);
15
+ ctx.sessionStore.setSessionIdAndSessionCwd(ctx.userId, ctx.agentKind, event.session_id, realCwd);
16
+ return;
17
+ }
18
+ const bootstrapSessionCwd = !hasCwd && eventCwd.length > 0 ? eventCwd : undefined;
19
+ ctx.sessionStore.setSessionIdAndCwd(ctx.userId, ctx.agentKind, event.session_id, realCwd, bootstrapSessionCwd);
20
+ }
21
+ export function writeBackTurnStarted(ctx, event) {
22
+ if (pointerMoved(ctx)) {
23
+ getLogger().info(`[lark-remote] turn_started write-back skipped: session pointer moved since run start runId=${ctx.runId} sessionId=${event.threadId}`);
24
+ return false;
25
+ }
26
+ ctx.sessionStore.setSessionIdAndCwd(ctx.userId, ctx.agentKind, event.threadId, ctx.cwd);
27
+ getLogger().info(`[lark-remote] turn_started session write-back runId=${ctx.runId} sessionId=${event.threadId}`);
28
+ return true;
29
+ }
30
+ export function writeBackResultSession(ctx, sessionId) {
31
+ if (!sessionId || pointerMoved(ctx))
32
+ return;
33
+ ctx.sessionStore.setSessionIdAndCwd(ctx.userId, ctx.agentKind, sessionId, ctx.cwd);
34
+ getLogger().info(`[lark-remote] result session write-back runId=${ctx.runId} sessionId=${sessionId}`);
35
+ }
36
+ //# sourceMappingURL=session-writeback.js.map
@@ -76,11 +76,8 @@ export class BashCardSession extends CardSession {
76
76
  if (this.state.terminal !== 'running') {
77
77
  return;
78
78
  }
79
- this.cancelPendingFlush();
80
- // 若有 in-flight flush 正在 await updateCard,必须等它完成后再发终态 patch,
81
- // 保证顺序(pre-terminal 先落地 → terminal 后落地 → terminal 胜出)。
82
- if (this.flushP)
83
- await this.flushP;
79
+ // 终态 patch 必须排在已开始的 pre-terminal flush 之后。
80
+ await this.prepareTerminalUpdate();
84
81
  this.state = {
85
82
  ...this.state,
86
83
  terminal,
@@ -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 {};
@@ -867,6 +867,20 @@ function measurePanelBytes(title, expanded, border, content) {
867
867
  * CardKit 2.0 renderer - simplified for streaming (no tabs)
868
868
  */
869
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) {
870
884
  // 先估后建:用廉价估算预测是否超 28KB 预算,明显超预算直接跳过完整卡构建走
871
885
  // degraded。groups/prepared 只算一次,测量与正式渲染共用同一份截断结果,
872
886
  // 避免影子测量 + 正式渲染对每个超限块双倍截断。
@@ -927,6 +941,110 @@ export function renderRunCard(state, options = {}) {
927
941
  };
928
942
  return truncatedSkeleton;
929
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
+ }
930
1048
  /**
931
1049
  * Run 卡片的双预算检查:UTF-8 字节 ≤ 28KB **且** 元素数 ≤ `MAX_CARD_ELEMENTS`。
932
1050
  *
@@ -1037,7 +1155,7 @@ function buildSummaryContent(state, options = {}) {
1037
1155
  }
1038
1156
  else {
1039
1157
  const result = state.resultSubtype ?? 'success';
1040
- const hasContent = state.blocks.length > 0;
1158
+ const hasContent = state.blocks.length > 0 || !!state.finalAnswer?.content.trim();
1041
1159
  // 压缩(operationKind='compaction')不会返回 agent 正文——引擎只压缩上下文
1042
1160
  // 窗口,空内容属正常,不能沿用普通 run 的「(未返回内容)」异常信号
1043
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 = 未归类(渲染默认文案)。 */