lark-remote 0.5.16 → 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 +22 -0
- package/dist/bridge/approval-actions.d.ts +1 -1
- package/dist/bridge/approval-actions.js +1 -1
- package/dist/bridge/index.d.ts +7 -4
- package/dist/bridge/index.js +20 -19
- package/dist/card/final-answer-file.d.ts +9 -0
- package/dist/card/final-answer-file.js +42 -0
- package/dist/card/run-renderer.d.ts +2 -2
- package/dist/card/run-renderer.js +13 -15
- package/dist/card/run-state.js +71 -7
- package/dist/dsh/types.d.ts +8 -0
- package/dist/runner/dsh/translator.d.ts +8 -0
- package/dist/runner/dsh/translator.js +36 -0
- package/dist/runner/types.d.ts +11 -0
- package/dist/session/dsh/sessions.d.ts +7 -2
- package/dist/session/dsh/sessions.js +24 -3
- package/package.json +1 -1
- package/dist/card/final-answer-overflow.d.ts +0 -1
- package/dist/card/final-answer-overflow.js +0 -46
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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
|
+
|
|
24
|
+
## [0.5.17] - 2026-11-01
|
|
25
|
+
|
|
26
|
+
### 维护
|
|
27
|
+
|
|
28
|
+
- 发布产物随源码一并重新构建,无行为变化
|
|
29
|
+
|
|
8
30
|
## [0.5.16] - 2026-11-01
|
|
9
31
|
|
|
10
32
|
### 修复
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -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
|
|
377
|
-
*
|
|
378
|
-
*
|
|
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
|
|
383
|
+
private sendFinalAnswerFile;
|
|
381
384
|
/**
|
|
382
385
|
* Update an existing card in place (原地更新) instead of sending a new one.
|
|
383
386
|
*
|
package/dist/bridge/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
846
|
-
*
|
|
847
|
-
*
|
|
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
|
|
850
|
-
|
|
853
|
+
async sendFinalAnswerFile(text, ctx) {
|
|
854
|
+
let filePath;
|
|
851
855
|
try {
|
|
852
|
-
|
|
853
|
-
|
|
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(
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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.
|
|
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));
|
|
@@ -1699,7 +1700,7 @@ export class Bridge {
|
|
|
1699
1700
|
// =============================================================================
|
|
1700
1701
|
// Approval action handlers
|
|
1701
1702
|
// =============================================================================
|
|
1702
|
-
// 实现住在 approval-actions.ts
|
|
1703
|
+
// 实现住在 approval-actions.ts:这 7 个入口只碰
|
|
1703
1704
|
// approvalCoordinators 一张表,不碰其余 Bridge 状态。方法名保持 Bridge 的
|
|
1704
1705
|
// 公开 API 不变——router 的 APPROVAL_ACTION_SPECS 按名字分发。
|
|
1705
1706
|
/** 审批响应(卡片按钮)→ ApprovalCoordinator.submit。 */
|
|
@@ -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
|
|
89
|
-
export declare const FINAL_ANSWER_OVERFLOW_MARKER = "_\u6700\u7EC8\u56DE\u7B54\u8F83\u957F\uFF0C\u5B8C\u6574\u5185\u5BB9\
|
|
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
|
|
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
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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,
|
package/dist/card/run-state.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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,保持既有长度不变量)。
|
package/dist/dsh/types.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) =>
|
|
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.
|
|
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
|