@fastagent-sh/fastagent 0.14.0 → 0.16.0

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 (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
@@ -11,7 +11,8 @@
11
11
  * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
12
  * one expired background file must not block the current ask or hide its still-readable siblings.
13
13
  */
14
- import { type Agent, type AgentEvent } from "../../agent.ts";
14
+ import type { Agent, AgentEvent } from "../../agent.ts";
15
+ import { type BusyRetry } from "../invoke-turn-kit.ts";
15
16
  import type { FeishuBufferedRef } from "./context-buffer.ts";
16
17
  import type { FeishuApi } from "./feishu-api.ts";
17
18
  /** Everything the transport needs to fetch a turn's attachments. */
@@ -23,7 +24,7 @@ export interface FeishuTurnTransport {
23
24
  }
24
25
  /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
25
26
  * bytes by message_id + key, so the pair travels together through the turn record). */
26
- export interface FeishuAttachmentInput {
27
+ interface FeishuAttachmentInput {
27
28
  msg: string;
28
29
  key: string;
29
30
  name?: string;
@@ -42,24 +43,10 @@ export interface FeishuTurnAttachments {
42
43
  skipped: number;
43
44
  };
44
45
  }
45
- /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
46
- * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
47
- * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
48
- * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
49
- export interface BusyRetry {
50
- delayMs: number;
51
- maxWaitMs: number;
52
- }
53
46
  /**
54
- * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
55
- * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
56
- * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
57
- * moment the turn provably lives in the session.
58
- *
59
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
60
- * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
61
- * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
62
- * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
63
- * engine emits it in, so nothing that started is ever re-run.
47
+ * Run one turn: resolve its inputs, then stream agent.invoke with the shared busy-wait
48
+ * (invoke-turn-kit `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
49
+ * primary-input failure surfaces as a `failed` event (never a silent drop).
64
50
  */
65
51
  export declare function invokeFeishuTurn(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
52
+ export {};
@@ -1,20 +1,7 @@
1
- /**
2
- * Run one turn (the IO half of canonical Feishu→Agent translation): assemble its inputs — resolve the reply
3
- * referent (a summon that replies to an earlier message names it only by `parent_id`; the content is
4
- * NOT in the event, so it is fetched here) and the attachments (vision images inline, files to disk) —
5
- * and stream `agent.invoke` with the assembled prompt. Split from parse.ts (which is pure) because this
6
- * half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
7
- * per-turn lifecycle.
8
- *
9
- * Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
10
- * any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
11
- * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
- * one expired background file must not block the current ask or hide its still-readable siblings.
13
- */
14
- import { SESSION_BUSY_CODE } from "../../agent.js";
15
1
  import { log } from "../../log.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
16
3
  import { parseContent } from "./parse.js";
17
- import { codePointPrefix } from "./text.js";
4
+ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
18
5
  /** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
19
6
  * card's markdown element is the natural fit for LLM output — steer away from HTML/plain. */
20
7
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
@@ -28,24 +15,41 @@ async function resolveTurnInputs(t, attachments) {
28
15
  let referentBlock = "";
29
16
  if (attachments.primary.parentId !== undefined) {
30
17
  const parentId = attachments.primary.parentId;
31
- const parent = await t.api.getMessage(parentId);
32
- if (!parent)
33
- throw new Error(`replied-to message ${parentId} is not readable`);
34
- const parsed = parseContent({
35
- message_type: parent.msg_type ?? "unknown",
36
- content: parent.body?.content ?? "",
37
- mentions: parent.mentions,
18
+ // A referent is CONTEXT, not the ask. Losing it (deleted, restricted, unreadable) must not cost
19
+ // the user their answer — every first message of a thread carries one, so a hard failure here
20
+ // would turn an ordinary platform edge into a lost turn. Degrade visibly instead: the operator
21
+ // gets a warning, and the model is told the quote could not be read rather than being left to
22
+ // guess what "about that" refers to.
23
+ // A deleted or invisible message comes back as an EMPTY item list rather than an error, so the
24
+ // warning belongs on the branch that renders the marker — that is the one the operator must see.
25
+ let failure;
26
+ const parent = await t.api.getMessage(parentId).catch((error) => {
27
+ failure = String(error);
28
+ return undefined;
38
29
  });
39
- // The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
40
- for (const key of parsed.imageKeys)
41
- images.push({ msg: parentId, key });
42
- for (const ref of parsed.fileRefs)
43
- files.push({ msg: parentId, key: ref.key, name: ref.name });
44
- // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
45
- // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
46
- const senderId = parent.sender?.id;
47
- const from = senderId ? `user ${senderId}` : undefined;
48
- referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${codePointPrefix(parsed.text, 560) || "(empty)"}]`;
30
+ if (!parent) {
31
+ log.warn(`${t.label} could not read replied-to message ${parentId} (${failure ?? "no such message"}) — the model is told the quote is unreadable`);
32
+ // Fall THROUGH: the resources this turn carries are the ask itself. Returning here would drop
33
+ // the images and files the user explicitly attached along with the referent they merely quoted.
34
+ referentBlock = `\n\n[replied-to message (msg ${parentId}) could not be read]`;
35
+ }
36
+ else {
37
+ const parsed = parseContent({
38
+ message_type: parent.msg_type ?? "unknown",
39
+ content: parent.body?.content ?? "",
40
+ mentions: parent.mentions,
41
+ });
42
+ // The referent's own resources join the turn as primary inputs, carried by the PARENT message id.
43
+ for (const key of parsed.imageKeys)
44
+ images.push({ msg: parentId, key });
45
+ for (const ref of parsed.fileRefs)
46
+ files.push({ msg: parentId, key: ref.key, name: ref.name });
47
+ // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
48
+ // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
49
+ const senderId = parent.sender?.id;
50
+ const from = senderId ? `user ${senderId}` : undefined;
51
+ referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
52
+ }
49
53
  }
50
54
  // Primary first and fail-fast: these are resources the current user explicitly pointed at.
51
55
  const imageRefs = [];
@@ -84,46 +88,25 @@ async function resolveTurnInputs(t, attachments) {
84
88
  log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
85
89
  }
86
90
  }
87
- const missing = lost + attachments.buffered.skipped;
88
- const bufferedNote = missing > 0
89
- ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
90
- : "";
91
- const backgroundImageManifest = backgroundImages.length
92
- ? `\n\n[background vision images from earlier discussion — appended after ${imageRefs.length} primary image(s):\n${backgroundImages
93
- .map(({ ref }, index) => `- vision image ${imageRefs.length + index + 1}: from ${ref.from}, msg ${ref.messageId}`)
94
- .join("\n")}\n]`
95
- : "";
91
+ const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
92
+ const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
96
93
  const allFiles = [
97
94
  ...downloaded,
98
95
  ...backgroundFiles.map(({ file, ref }) => ({
99
96
  ...file,
100
- name: `${file.name} (from ${ref.from}, msg ${ref.messageId}, earlier discussion)`,
97
+ name: attributedFileName(file.name, ref.from, ref.messageId),
101
98
  })),
102
99
  ];
103
- const manifest = allFiles.length
104
- ? `\n\n[attached files — read them with your tools:\n${allFiles.map((file) => `- ${file.name} (${file.size} bytes) → ${file.path}`).join("\n")}\n]`
105
- : "";
106
100
  const allImages = [...imageRefs, ...backgroundImages.map(({ image }) => image)];
107
101
  return {
108
102
  images: allImages.length ? allImages : undefined,
109
- promptSuffix: `${referentBlock}${bufferedNote}${backgroundImageManifest}${manifest}`,
103
+ promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
110
104
  };
111
105
  }
112
- // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
113
- // free, and the loop exits within one delay of the holder finishing. The cap is sized to outlast a real
114
- // tool-using wake turn (minutes); a holder that runs longer still surfaces the busy error to the user.
115
- const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
116
106
  /**
117
- * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
118
- * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
119
- * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
120
- * moment the turn provably lives in the session.
121
- *
122
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
123
- * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
124
- * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
125
- * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
126
- * engine emits it in, so nothing that started is ever re-run.
107
+ * Run one turn: resolve its inputs, then stream agent.invoke with the shared busy-wait
108
+ * (invoke-turn-kit `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
109
+ * primary-input failure surfaces as a `failed` event (never a silent drop).
127
110
  */
128
111
  export async function* invokeFeishuTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
129
112
  let resolved;
@@ -135,23 +118,5 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
135
118
  return;
136
119
  }
137
120
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
138
- const deadline = Date.now() + busyRetry.maxWaitMs;
139
- for (;;) {
140
- let retryBusy = false;
141
- let first = true;
142
- for await (const e of agent.invoke({ session }, prompt)) {
143
- if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
144
- retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
145
- break;
146
- }
147
- first = false;
148
- if (e.type === "completed")
149
- onCompleted?.(); // the turn is durably in the session — commit point
150
- yield e;
151
- }
152
- if (!retryBusy)
153
- return;
154
- log.info(`${transport.label} session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
155
- await new Promise((r) => setTimeout(r, busyRetry.delayMs));
156
- }
121
+ yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
157
122
  }
@@ -76,7 +76,7 @@ export type FeishuResourceKind = "image" | "file" | "audio" | "video";
76
76
  * A resource locator. User-sent resources are scoped by BOTH their carrying message id and resource
77
77
  * key; a bare file_key/image_key is insufficient for the message-resource download API.
78
78
  */
79
- export interface FeishuResourceRef {
79
+ interface FeishuResourceRef {
80
80
  kind: FeishuResourceKind;
81
81
  messageId: string;
82
82
  key: string;
@@ -87,7 +87,6 @@ export interface NormalizedFeishuMessage {
87
87
  conversation: {
88
88
  chatId: string;
89
89
  threadId?: string;
90
- rootId?: string;
91
90
  };
92
91
  content: {
93
92
  text: string;
@@ -95,3 +94,4 @@ export interface NormalizedFeishuMessage {
95
94
  resources: FeishuResourceRef[];
96
95
  };
97
96
  }
97
+ export {};
@@ -4,7 +4,7 @@
4
4
  * converts resource keys into message-scoped locators before the turn engine sees them.
5
5
  */
6
6
  import type { FeishuMessage, FeishuMessageEvent, FeishuResourceKind, NormalizedFeishuMessage } from "./model.ts";
7
- export interface DecodedFeishuResource {
7
+ interface DecodedFeishuResource {
8
8
  kind: FeishuResourceKind;
9
9
  key: string;
10
10
  name?: string;
@@ -20,3 +20,4 @@ export interface DecodedFeishuContent {
20
20
  export declare function decodeFeishuContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): DecodedFeishuContent;
21
21
  /** Normalize one verified message event. Returns null only when its required identity is absent. */
22
22
  export declare function normalizeFeishuMessage(event: FeishuMessageEvent): NormalizedFeishuMessage | null;
23
+ export {};
@@ -121,7 +121,6 @@ export function normalizeFeishuMessage(event) {
121
121
  conversation: {
122
122
  chatId: message.chat_id,
123
123
  threadId: message.thread_id,
124
- rootId: message.root_id,
125
124
  },
126
125
  content: {
127
126
  text: decoded.text,
@@ -7,9 +7,9 @@
7
7
  import type { FeishuCloudKind } from "./cloud.ts";
8
8
  import type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender } from "./model.ts";
9
9
  export type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender };
10
- /** Legacy compatibility shape returned by {@link parseContent}. New internal code consumes normalized
11
- * resource refs, which retain resource kind + carrying message id. */
12
- export interface FeishuAttachmentRef {
10
+ /** A resource reduced to what a caller needs to fetch and name it — the kind and carrying message id
11
+ * that {@link decodeFeishuContent} attaches are supplied by the caller's own context. */
12
+ interface FeishuAttachmentRef {
13
13
  key: string;
14
14
  name?: string;
15
15
  }
@@ -19,14 +19,27 @@ export interface ParsedFeishuContent {
19
19
  fileRefs: FeishuAttachmentRef[];
20
20
  }
21
21
  /**
22
- * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
23
- * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
22
+ * The decoder projected onto flat per-kind arrays, for callers that resolve a message on their own
23
+ * (the prompt envelope, and the quoted parent whose resources are carried by the PARENT message id).
24
+ * `decodeFeishuContent` stays canonical: this only reshapes what it returns.
24
25
  */
25
26
  export declare function parseContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): ParsedFeishuContent;
26
27
  /** A stable sender label for attribution. Display names require an additional contacts permission. */
27
28
  export declare function senderLabel(sender: FeishuSender | undefined): string | undefined;
28
- /** The place a message lives (chat, or chat:topic in a topic group) the legacy default session key. */
29
- export declare function placeKey(message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
29
+ /** Whichever id flavour the tenant populates. `sender_id` is a union and which members are filled is
30
+ * app configuration, not an invariant callers only ever compare these for distinctness. */
31
+ export declare function senderId(sender: FeishuSender | undefined): string | undefined;
32
+ /**
33
+ * The place a message lives (the chat, or a thread within it) — the session key (participant model §5),
34
+ * and the key thread participation is recorded under, since that record is a claim about this session.
35
+ *
36
+ * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
37
+ * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
38
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
39
+ * percent-encodes it, so each `:` costs three), and the worst case here — brand + a 35-char chat id +
40
+ * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
41
+ */
42
+ export declare function placeKey(kind: string, message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
30
43
  /** The canonical Feishu-branded prompt envelope. */
31
44
  export declare function feishuEnvelope(event: FeishuMessageEvent): string;
32
45
  /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
@@ -36,7 +49,8 @@ export declare function mentionsBot(message: Pick<FeishuMessage, "mentions">, bo
36
49
  /**
37
50
  * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
38
51
  * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
39
- * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
52
+ * stateful channel wiring may additionally admit unmentioned messages in a thread it takes part in
53
+ * (docs/design/participant-model.md §3).
40
54
  */
41
55
  export declare function defaultFeishuRoute(event: FeishuMessageEvent, options?: {
42
56
  botOpenId?: string;
@@ -1,7 +1,8 @@
1
1
  import { decodeFeishuContent } from "./normalize.js";
2
2
  /**
3
- * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
4
- * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
3
+ * The decoder projected onto flat per-kind arrays, for callers that resolve a message on their own
4
+ * (the prompt envelope, and the quoted parent whose resources are carried by the PARENT message id).
5
+ * `decodeFeishuContent` stays canonical: this only reshapes what it returns.
5
6
  */
6
7
  export function parseContent(message) {
7
8
  const decoded = decodeFeishuContent(message);
@@ -15,12 +16,27 @@ export function parseContent(message) {
15
16
  }
16
17
  /** A stable sender label for attribution. Display names require an additional contacts permission. */
17
18
  export function senderLabel(sender) {
18
- const id = sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
19
+ const id = senderId(sender);
19
20
  return id ? `user ${id}` : undefined;
20
21
  }
21
- /** The place a message lives (chat, or chat:topic in a topic group) the legacy default session key. */
22
- export function placeKey(message) {
23
- return message.thread_id ? `${message.chat_id}:${message.thread_id}` : message.chat_id;
22
+ /** Whichever id flavour the tenant populates. `sender_id` is a union and which members are filled is
23
+ * app configuration, not an invariant — callers only ever compare these for distinctness. */
24
+ export function senderId(sender) {
25
+ return sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
26
+ }
27
+ /**
28
+ * The place a message lives (the chat, or a thread within it) — the session key (participant model §5),
29
+ * and the key thread participation is recorded under, since that record is a claim about this session.
30
+ *
31
+ * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
32
+ * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
33
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
34
+ * percent-encodes it, so each `:` costs three), and the worst case here — brand + a 35-char chat id +
35
+ * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
36
+ */
37
+ export function placeKey(kind, message) {
38
+ const chat = `${kind}:${message.chat_id}`;
39
+ return message.thread_id ? `${chat}:${message.thread_id}` : chat;
24
40
  }
25
41
  /** The canonical Feishu-branded prompt envelope. */
26
42
  export function feishuEnvelope(event) {
@@ -52,7 +68,8 @@ export function mentionsBot(message, botOpenId) {
52
68
  /**
53
69
  * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
54
70
  * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
55
- * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
71
+ * stateful channel wiring may additionally admit unmentioned messages in a thread it takes part in
72
+ * (docs/design/participant-model.md §3).
56
73
  */
57
74
  export function defaultFeishuRoute(event, options) {
58
75
  const message = event.message;
@@ -1,12 +1,9 @@
1
1
  import type { AgentEvent } from "../../agent.ts";
2
2
  import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
3
- /** A terminal failure, as the channel hands it to `onError`. */
4
- export interface FeishuFailure {
5
- details: string;
6
- retryable: boolean;
7
- }
8
- /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
9
- export declare function defaultErrorMessage(failed: FeishuFailure): string;
3
+ import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
4
+ /** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
5
+ export type FeishuFailure = ChannelFailure;
6
+ export { defaultErrorMessage };
10
7
  /** A visible preview mounted into the chat. Exported only for the channel wiring: a queued turn mounts
11
8
  * one before execution, then hands the exact entity/message to {@link streamFeishuReply} for takeover. */
12
9
  export type MountedFeishuPreview = {
@@ -23,41 +23,16 @@ import { setTimeout as sleep } from "node:timers/promises";
23
23
  import { log } from "../../log.js";
24
24
  import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
25
25
  import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
26
- import { truncateCodePointPrefix, truncateCodePointSuffix, truncateUtf8 } from "./text.js";
27
- /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
28
- export function defaultErrorMessage(failed) {
29
- return failed.retryable ? "⚠️ Temporary problem — please try again." : "⚠️ Sorry, something went wrong.";
30
- }
26
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
27
+ import { truncateUtf8 } from "../text.js";
28
+ export { defaultErrorMessage };
31
29
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
30
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
33
31
  * animates between snapshots).
34
32
  * Doubles as the answer-preview aging window (see answerView). */
35
33
  const STREAM_THROTTLE_MS = 1000;
36
- /** Max length of a tool's arg preview in the live view. */
37
- const TOOL_ARG_MAX = 48;
38
34
  /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
39
35
  const THINKING_PREVIEW = 280;
40
- /** The placeholder shown before any reasoning/tool/text arrives. */
41
- const THINKING_PLACEHOLDER = "💭 Thinking…";
42
- /** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
43
- function clip(s) {
44
- const one = s.replace(/\s+/g, " ").trim();
45
- return truncateCodePointPrefix(one, TOOL_ARG_MAX);
46
- }
47
- /**
48
- * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
49
- * rather than just `🔧 read`. Generic (the channel knows no tool schemas): show the salient value — the
50
- * first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
51
- */
52
- function summarizeArgs(args) {
53
- if (args === null || typeof args !== "object" || Array.isArray(args))
54
- return clip(String(args));
55
- const values = Object.values(args);
56
- const primary = values.find((v) => typeof v === "string" || typeof v === "number");
57
- if (primary !== undefined)
58
- return clip(String(primary));
59
- return values.length > 0 ? clip(JSON.stringify(args)) : "";
60
- }
61
36
  /** Cap a live view to the card budget, PREFIX-STABLE: the streaming client animates only when the old
62
37
  * text is a prefix of the new, so an over-budget view freezes at its head rather than sliding a tail
63
38
  * window (which would redraw the whole card every frame). The full answer still lands at settle. */
@@ -88,8 +63,9 @@ async function finalize(api, target, preview, text, seq) {
88
63
  // Settle failed (card expired / rejected) — fall through to delete + fresh send below.
89
64
  }
90
65
  if (settled) {
91
- // Threaded continuations must keep reply_in_thread; continuous top-level group replies intentionally
92
- // avoid repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
66
+ // Threaded continuations must keep reply_in_thread; top-level group replies (target.replyInThread
67
+ // unset a custom route, or an ask outside a thread) intentionally avoid repeating the quote on
68
+ // every chunk. sendText owns the same distinction for its own chunking.
93
69
  // A continuation failure propagates: the card is already authoritative, so deleting it and sending
94
70
  // the full answer again would deterministically duplicate every continuation that already landed.
95
71
  const continuationTarget = target.replyInThread ? target : { chatId: target.chatId };
@@ -174,35 +150,16 @@ export async function settleFeishuPreview(api, target, preview, text) {
174
150
  * than recalling it and posting another reply.
175
151
  */
176
152
  export async function streamFeishuReply(events, api, target, formatError, initialPreview, label = "[feishu]") {
177
- const tools = [];
178
- const toolIndexById = new Map();
179
- let thinking = "";
180
- let answer = "";
181
- let answerPreviewSince;
182
- const mark = { running: "…", ok: "✓", error: "✗" };
183
- const toolView = () => tools.map((t) => `🔧 ${t.label} ${mark[t.status]}`).join("\n");
184
- // Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
185
- // in the settled final card (which is `answer` alone).
186
- const thinkingView = () => {
187
- const t = thinking.replace(/\s+/g, " ").trim();
188
- if (t === "")
189
- return "";
190
- return `💭 ${truncateCodePointSuffix(t, THINKING_PREVIEW)}`;
191
- };
192
- // The answer is hidden until its first delta has aged one STREAM_THROTTLE_MS: the pump's leading-edge
193
- // flush would otherwise turn the very first content delta (often a lone character) into its own frame
194
- // — the short-reply flicker. Aging is anchored at delta ARRIVAL (set in the event loop, not here) so
195
- // an in-flight update can't skew the clock; a turn completing within the window settles directly.
196
- const answerView = () => {
197
- if (answer.trim() === "" || answerPreviewSince === undefined)
198
- return "";
199
- return Date.now() - answerPreviewSince >= STREAM_THROTTLE_MS ? answer : "";
200
- };
153
+ // Event view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
154
+ // policy, the card-budget cap, and delivery below.
155
+ const turn = createTurnView();
201
156
  const view = () => {
202
- const v = [thinkingView(), toolView(), answerView()]
203
- .filter((s) => s.trim() !== "")
204
- .join("\n\n")
205
- .trim();
157
+ const v = composeTurnBody([
158
+ thinkingLine(turn, THINKING_PREVIEW),
159
+ toolLines(turn),
160
+ turn.retrying ? RETRY_NOTICE : "",
161
+ revealedAnswer(turn, STREAM_THROTTLE_MS),
162
+ ]);
206
163
  return capBytes(v === "" ? THINKING_PLACEHOLDER : v, CARD_MARKDOWN_MAX_BYTES);
207
164
  };
208
165
  // The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
@@ -243,105 +200,31 @@ export async function streamFeishuReply(events, api, target, formatError, initia
243
200
  throw e;
244
201
  }
245
202
  };
246
- // ── Live-preview pump: a SINGLE serialized writer. ──────────────────────────────────────────
247
- // Events mutate state (thinking / tools / answer) and mark the preview dirty; the pump pushes the
248
- // LATEST view() with at most ONE update in flight, paced by a throttle. One-in-flight also guarantees
249
- // the card's strictly-increasing `sequence` lands in order (no concurrent frames).
250
- let dirty = false;
251
- let pumping = false;
252
- let stopped = false;
253
- let previewErrLogged = false;
254
- let pumpDone;
255
- let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
256
- const runPump = async () => {
257
- pumping = true;
258
- try {
259
- while (dirty && !stopped) {
260
- dirty = false;
261
- try {
262
- await flushPreview();
263
- }
264
- catch (e) {
265
- // Best-effort preview (the final write is authoritative), but a failing update must be visible —
266
- // log once per turn so a never-rendering preview is diagnosable, not silent.
267
- if (!previewErrLogged) {
268
- previewErrLogged = true;
269
- log.warn(`${label} live preview failed (final reply still sends): ${String(e)}`);
270
- }
271
- }
272
- if (dirty && !stopped) {
273
- // Pace + coalesce a burst into one snapshot. Interruptible: finish() cuts this short so the
274
- // final write is not delayed by up to STREAM_THROTTLE_MS after the turn completes.
275
- await new Promise((resolve) => {
276
- const t = setTimeout(resolve, STREAM_THROTTLE_MS);
277
- wakeThrottle = () => {
278
- clearTimeout(t);
279
- resolve();
280
- };
281
- });
282
- wakeThrottle = undefined;
283
- }
284
- }
285
- }
286
- finally {
287
- pumping = false;
288
- }
289
- };
290
- // Mark the preview dirty and ensure the single writer is running (an update already in flight picks
291
- // up the new state on its next loop). Synchronous — callers never await a network write.
292
- const touch = () => {
293
- dirty = true;
294
- if (!pumping)
295
- pumpDone = runPump();
296
- };
203
+ // The shared single-writer pump (preview-kit) serializes snapshots to the one preview — which also
204
+ // guarantees the card's strictly-increasing `sequence` lands in order (no concurrent frames).
205
+ const { touch, finish } = createPreviewPump({
206
+ flush: flushPreview,
207
+ throttleMs: STREAM_THROTTLE_MS,
208
+ onError: (e) => log.warn(`${label} live preview failed (final reply still sends): ${String(e)}`),
209
+ });
297
210
  touch(); // mount the "💭 Thinking…" preview immediately
298
- // Stop the pump and await any in-flight update, so the final write below is strictly the LAST one to
299
- // the preview (no stale frame landing after the answer).
300
- const finish = async () => {
301
- stopped = true;
302
- wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to STREAM_THROTTLE_MS
303
- await pumpDone?.catch(() => { });
304
- };
305
211
  /** Terminal write, whatever tier the preview reached. */
306
212
  const settle = async (text) => {
307
213
  await finalize(api, target, preview, text, nextSeq);
308
214
  };
309
215
  try {
310
216
  for await (const e of events) {
311
- if (e.type === "text") {
312
- answer += e.delta;
313
- if (answerPreviewSince === undefined && answer.trim() !== "")
314
- answerPreviewSince = Date.now();
315
- touch();
316
- }
317
- else if (e.type === "thinking") {
318
- thinking += e.delta;
319
- touch();
320
- }
321
- else if (e.type === "tool_started") {
322
- const arg = summarizeArgs(e.args);
323
- toolIndexById.set(e.id, tools.length);
324
- tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
325
- touch();
326
- }
327
- else if (e.type === "tool_ended") {
328
- const i = toolIndexById.get(e.id);
329
- const t = i === undefined ? undefined : tools[i];
330
- if (t)
331
- t.status = e.isError ? "error" : "ok";
332
- touch();
333
- }
334
- else if (e.type === "completed") {
217
+ if (e.type === "completed") {
335
218
  await finish();
336
219
  // Settle the preview into the final answer; the persisted card is the answer alone — the
337
220
  // process (thinking/tools) was preview-only. Mark finalized BEFORE delivering: the terminal was
338
221
  // reached, so a delivery failure here is a plain failure, not an "abnormal exit" (which would
339
222
  // wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
340
223
  finalized = true;
341
- await settle(answer.trim() !== "" ? answer : "(no reply)");
224
+ await settle(turn.answer.trim() !== "" ? turn.answer : "(no reply)");
342
225
  return;
343
226
  }
344
- else if (e.type === "failed") {
227
+ if (e.type === "failed") {
345
228
  await finish();
346
229
  // Two audiences: the chat (customer-facing — formatError, neutral by default) and the operator
347
230
  // log (dev-facing — the full details, via the throw below + the handler's catch). Same terminal
@@ -361,6 +244,8 @@ export async function streamFeishuReply(events, api, target, formatError, initia
361
244
  }
362
245
  throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
363
246
  }
247
+ if (applyTurnEvent(turn, e))
248
+ touch();
364
249
  }
365
250
  throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
366
251
  }
@@ -21,7 +21,7 @@
21
21
  * encoding (JSON → gzip → base64url) follow the official SDKs (provenance: node-sdk
22
22
  * scene/registration); item names unknown to the platform catalog are silently dropped by the page.
23
23
  */
24
- export interface FeishuAppAddons {
24
+ interface FeishuAppAddons {
25
25
  scopes?: {
26
26
  tenant?: string[];
27
27
  user?: string[];
@@ -68,3 +68,4 @@ export interface RegisteredFeishuApp {
68
68
  * abort, or a transport failure — every rejection is a plain Error whose message says what to do.
69
69
  */
70
70
  export declare function registerFeishuApp(options: RegisterFeishuAppOptions): Promise<RegisteredFeishuApp>;
71
+ export {};