@fastagent-sh/fastagent 0.13.0 → 0.15.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 (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  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 +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -1,27 +1,34 @@
1
1
  /**
2
- * Canonical Feishu bot-channel engine: verify webhook answer url_verification → dedup → route → run
3
- * the turn → stream a live card → ACK 200. Feishu (open.feishu.cn) is the reference cloud. Lark
2
+ * Canonical Feishu bot-channel engine: verified webhook or official-SDK WebSocket → dedup → route →
3
+ * persist enqueue → stream a live card. Feishu (open.feishu.cn) is the reference cloud. Lark
4
4
  * international binds this engine through an explicit compatibility profile because its control plane
5
5
  * trails Feishu; protocol reuse does not make Lark the design center.
6
6
  *
7
7
  * The channel kind remains the unit of route, env namespace, state home, logs, and onboarding, so one
8
- * workspace may mount both without sharing state. Webhook mode only; WebSocket long connection needs
9
- * the official SDK and a non-HTTP ingress seam. See docs/feishu.md.
8
+ * workspace may run both without sharing state. Webhook returns the existing route factory; WebSocket
9
+ * returns an explicit long-connection module. Both feed the same acceptance/turn engine. See docs/feishu.md.
10
10
  */
11
11
  import { isAbsolute, join } from "node:path";
12
12
  import { log } from "../../log.js";
13
13
  import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
+ import { createSeenRing } from "../seen.js";
16
+ import { createTaskTracker } from "../tasks.js";
15
17
  import { ensureStateHome } from "../state.js";
18
+ import { dispatchStop, isStopText } from "../stop-command.js";
16
19
  import { createTurnQueue } from "../turn-queue.js";
17
20
  import { createTurnStore } from "../turn-store.js";
18
21
  import { FEISHU_CLOUD } from "./cloud.js";
22
+ import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
19
23
  import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
20
24
  import { invokeFeishuTurn } from "./invoke-turn.js";
21
25
  import { createFeishuApi } from "./feishu-api.js";
22
- import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, parseContent, placeKey, } from "./parse.js";
26
+ import { normalizeFeishuMessage } from "./normalize.js";
27
+ import { createOwnedFeishuThreads } from "./owned-threads.js";
28
+ import { FEISHU_GROUP_CONTEXT_SCOPE } from "./setup-mode.js";
29
+ import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderLabel, } from "./parse.js";
23
30
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
24
- import { createSeenRing } from "./seen.js";
31
+ import { connectFeishuWs } from "./ws-ingress.js";
25
32
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
26
33
  export { defaultFeishuRoute, feishuEnvelope };
27
34
  /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
@@ -45,6 +52,7 @@ function isStoredFeishuTurn(t) {
45
52
  typeof r.seq === "number" &&
46
53
  typeof r.session === "string" &&
47
54
  typeof r.baseText === "string" &&
55
+ (r.bufferKey === undefined || typeof r.bufferKey === "string") &&
48
56
  typeof r.chatId === "string" &&
49
57
  (r.replyTo === undefined || typeof r.replyTo === "string") &&
50
58
  (r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
@@ -54,24 +62,33 @@ function isStoredFeishuTurn(t) {
54
62
  refs(r.files) &&
55
63
  typeof r.attempts === "number");
56
64
  }
57
- /** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
65
+ /** Build the canonical Feishu Request-URL webhook channel. */
58
66
  export function feishuChannel(opts) {
59
67
  return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
60
68
  }
61
- /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
62
- export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
63
- const { kind, envPrefix } = profile;
64
- const label = `[${kind}]`;
65
- // All three are mandatory: without the app credentials no reply can be sent; without the verification
66
- // token a plaintext-mode endpoint would accept forged events. Fail at construction (startup), not
67
- // silently at the first event.
68
- if (!appId || !appSecret) {
69
- throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
69
+ /** Build the canonical Feishu WebSocket long-connection channel. */
70
+ export function feishuWebSocketChannel(opts) {
71
+ return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
72
+ }
73
+ function validateSessionOptions(opts, factoryName) {
74
+ if (opts.directMessageSession !== undefined && !["continuous", "threaded"].includes(opts.directMessageSession)) {
75
+ throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
70
76
  }
71
- if (!verificationToken) {
72
- throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks; an unset one accepts forged events)`);
77
+ if (opts.groupMessageSession !== undefined && !["continuous", "threaded"].includes(opts.groupMessageSession)) {
78
+ throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
73
79
  }
74
- return ({ agent, stateRoot }) => {
80
+ }
81
+ function createFeishuRuntimeFactory(profile, opts, factoryName) {
82
+ const { appId, appSecret, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, } = opts;
83
+ const baseUrl = opts.apiBaseUrl ?? opts.baseUrl ?? profile.apiBase;
84
+ const { kind } = profile;
85
+ const label = `[${kind}]`;
86
+ return ({ agent, stateRoot, control }) => {
87
+ // Credential checks run when serving starts, not while the authored module is imported: deployment
88
+ // can inspect the module shape before secrets exist, while serving still fails before ready.
89
+ if (!appId || !appSecret) {
90
+ throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
91
+ }
75
92
  const formatError = onError ?? defaultErrorMessage;
76
93
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
77
94
  // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
@@ -82,6 +99,17 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
82
99
  if (!botOpenId)
83
100
  log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
84
101
  }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
102
+ void api.listAppScopes().then((scopes) => {
103
+ const contextAware = scopes.some((scope) => scope.name === FEISHU_GROUP_CONTEXT_SCOPE &&
104
+ scope.grantStatus === 1 &&
105
+ (scope.type === undefined || scope.type === "tenant"));
106
+ if (contextAware) {
107
+ log.info(`${label} group visibility: context-aware — bare managed-thread replies + buffered discussion enabled`);
108
+ }
109
+ else {
110
+ log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare managed-thread replies + group context buffering are unavailable`);
111
+ }
112
+ }, (error) => log.warn(`${label} could not inspect group visibility: ${String(error)}`));
85
113
  const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
86
114
  // The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
87
115
  // (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
@@ -90,13 +118,17 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
90
118
  throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
91
119
  }
92
120
  const stateHome = join(stateRoot, "channels", kind);
93
- ensureStateHome(stateHome); // create + self-ignore — downloaded files may carry chat content
121
+ ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
122
+ const ownedThreads = createOwnedFeishuThreads(join(stateHome, "owned-threads.json"), label);
123
+ const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
94
124
  const store = createTurnStore(join(stateHome, "turns.json"), {
95
125
  label,
96
126
  isRecord: isStoredFeishuTurn,
97
127
  order: (a, b) => a.seq - b.seq,
98
128
  });
99
129
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
130
+ // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
131
+ const sideTasks = createTaskTracker();
100
132
  const toStored = (r) => {
101
133
  const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
102
134
  return { ...intent, attempts: 0 };
@@ -177,10 +209,24 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
177
209
  }
178
210
  const startedAt = Date.now();
179
211
  log.info(`${label} turn start: turn=${rec.id} session=${rec.session} chat=${rec.chatId}`);
212
+ // Snapshot background discussion at dequeue. Commit only this snapshot on `completed`, so a
213
+ // message arriving while the turn runs remains buffered for the next answered turn.
214
+ // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
215
+ // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
216
+ // would instead couple otherwise-independent root sessions and require failure rollback.
217
+ const { text: recent, consumed } = buffer.peek(rec.bufferKey);
218
+ const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
219
+ const buffered = collectFeishuBufferedAttachments(consumed, {
220
+ images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
221
+ files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
222
+ });
180
223
  try {
181
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, rec.baseText, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { images: rec.images, files: rec.files, parentId: rec.parentId },
182
- // On completed, drop the intent the turn provably lives in the session from here on.
183
- () => store.remove(rec.id)), api, targetOf(rec), formatError, rec.preview, label);
224
+ await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
225
+ // Drop intent first: a crash between these writes may re-fold answered context later,
226
+ // but can never replay this turn after its context was removed.
227
+ store.remove(rec.id);
228
+ buffer.commit(rec.bufferKey, consumed);
229
+ }), api, targetOf(rec), formatError, rec.preview, label);
184
230
  log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
185
231
  }
186
232
  catch (error) {
@@ -195,13 +241,13 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
195
241
  }
196
242
  },
197
243
  });
198
- // Accept a turn: persist its intent (pre-ACK; a failed write throws webhook 500 → redeliver),
199
- // record its id in the dedup ring (post-decision insurance, best-effort), enqueue it. Recovery
200
- // re-enqueues a crash-surviving turn WITHOUT re-persisting.
244
+ // Accept a turn: persist its intent before the ACK, then record the platform delivery id and enqueue
245
+ // it. The ordering is deliberate: recording first could turn a failed intent write into silent loss
246
+ // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
201
247
  const submit = (rec, persist) => {
202
248
  if (persist) {
203
- store.add(toStored(rec));
204
- seen.add(rec.id);
249
+ store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
250
+ seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
205
251
  }
206
252
  queue.accept(rec);
207
253
  };
@@ -218,142 +264,231 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
218
264
  if (recovered.length > 0)
219
265
  log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
220
266
  let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
221
- for (const { attempts: _a, ...intent } of recovered)
222
- submit({ ...intent, preview: undefined }, false);
223
- const handler = async (req) => {
224
- if (req.method !== "POST")
225
- return text("POST only\n", 405);
226
- const body = await readBodyCapped(req, MAX_EVENT_BYTES);
227
- if ("tooLarge" in body)
228
- return text("payload too large\n", 413);
229
- let outer;
230
- try {
231
- outer = JSON.parse(body.text);
232
- if (typeof outer !== "object" || outer === null)
233
- throw new Error("not an object");
234
- }
235
- catch {
236
- return text("invalid json\n", 400);
267
+ for (const { attempts: _a, ...intent } of recovered) {
268
+ // A pre-buffer-version record has no trustworthy place identity. Give it an empty private bucket
269
+ // rather than risk consuming new main-chat context that arrived after this restart.
270
+ const bufferKey = intent.bufferKey ?? `${intent.chatId}:legacy-turn:${intent.id}`;
271
+ submit({ ...intent, bufferKey, preview: undefined }, false);
272
+ }
273
+ // Transport-neutral acceptance boundary. It performs only the fast pre-ACK work: normalize,
274
+ // route, persist intent/context, and enqueue. The minutes-long Agent turn remains fire-and-forget.
275
+ const acceptEvent = (event) => {
276
+ const m = event.message;
277
+ if (!m?.message_id || !m.chat_id)
278
+ return;
279
+ if (seen.has(m.message_id)) {
280
+ log.debug(`${label} duplicate push for message ${m.message_id} — already persisted, skipping`);
281
+ return;
237
282
  }
238
- // ── Verification. Two modes, decided by the CONSOLE's Encrypt Key setting, mirrored here. ──────
239
- let envelope;
240
- if (typeof outer.encrypt === "string") {
241
- if (!encryptKey) {
242
- log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
243
- return text("encrypt key not configured\n", 400);
244
- }
245
- const sig = {
246
- timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
247
- nonce: req.headers.get("x-lark-request-nonce") ?? "",
248
- signature: req.headers.get("x-lark-signature") ?? "",
249
- };
250
- // Ordinary encrypted events MUST verify the signature over the raw body before decryption.
251
- // Feishu's documented exception is Request URL verification: its encrypted challenge carries
252
- // no event-signature headers, so it is decrypted first and admitted ONLY when its type is
253
- // url_verification; the common constant-time Token check below then authenticates it.
254
- if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
255
- log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
256
- return text("invalid signature\n", 401);
257
- }
258
- try {
259
- envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
260
- }
261
- catch {
262
- if (!sig.signature) {
263
- log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
264
- return text("invalid encrypted payload\n", 401);
283
+ let r = decide(event);
284
+ const normalized = normalizeFeishuMessage(event);
285
+ if (!normalized)
286
+ return;
287
+ const bufferKey = feishuBufferPlaceKey(normalized.conversation);
288
+ const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
289
+ const managedThread = groupMessageSession === "threaded" &&
290
+ isHumanGroup &&
291
+ m.thread_id !== undefined &&
292
+ m.root_id !== undefined &&
293
+ ownedThreads.has(m.chat_id, m.root_id);
294
+ if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
295
+ r = {};
296
+ if (!r) {
297
+ if (route === undefined && isHumanGroup) {
298
+ const bodyText = feishuBufferText(normalized.content.text);
299
+ if (bodyText) {
300
+ const resources = normalized.content.resources;
301
+ const images = resources
302
+ .filter((resource) => resource.kind === "image")
303
+ .map((resource) => ({ messageId: resource.messageId, key: resource.key }));
304
+ const files = resources
305
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
306
+ .map((resource) => ({
307
+ messageId: resource.messageId,
308
+ key: resource.key,
309
+ name: resource.name,
310
+ }));
311
+ // A write failure escapes this boundary. HTTP turns it into a 500 response; the official WS
312
+ // SDK turns it into a 500 ACK frame. Both transports therefore ask the platform to re-push.
313
+ buffer.push(bufferKey, {
314
+ sender: senderLabel(event.sender) ?? "someone",
315
+ body: bodyText,
316
+ messageId: m.message_id,
317
+ replyTo: m.parent_id,
318
+ files: files.length ? files : undefined,
319
+ images: images.length ? images : undefined,
320
+ });
321
+ seen.add(m.message_id);
322
+ log.debug(`${label} buffered unsummoned group message ${m.message_id} (place ${bufferKey})`);
323
+ }
324
+ else {
325
+ log.debug(`${label} not summoned — ignoring empty message ${m.message_id} (chat ${m.chat_id})`);
265
326
  }
266
- return text("invalid encrypted payload\n", 400);
267
327
  }
268
- if (!sig.signature && envelope.type !== "url_verification") {
269
- log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
270
- return text("invalid signature\n", 401);
328
+ else {
329
+ log.debug(`${label} not summoned ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
271
330
  }
331
+ return;
272
332
  }
273
- else {
274
- if (encryptKey) {
275
- // With an Encrypt Key configured, a PLAINTEXT event can only be a forgery (or a console
276
- // mismatch surfaced in the log): accepting it would let a sender skip the signature.
277
- log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
278
- return text("plaintext events not accepted\n", 401);
279
- }
280
- envelope = outer;
333
+ const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
334
+ const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
335
+ const threadedConversation = threadedP2p || threadedGroup;
336
+ if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
337
+ log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id session continuity cannot be guaranteed`);
281
338
  }
282
- // The Verification Token authenticates plaintext mode and the platform-documented unsigned,
283
- // encrypted URL challenge; on signed encrypted events it is defense in depth. V2 events carry it
284
- // in header.token, while url_verification carries it at the top level. Fail closed when absent.
285
- const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
286
- (typeof envelope.header?.token === "string"
287
- ? envelope.header.token
288
- : undefined);
289
- if (!token || !timingSafeEqualStr(token, verificationToken)) {
290
- // Loud on purpose: the send side gets an opaque 401 and the platform just retries — this line is
291
- // the operator's ONLY signal that LARK_VERIFICATION_TOKEN does not match the console.
292
- log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
293
- return text("invalid token\n", 401);
339
+ const defaultSession = threadedConversation
340
+ ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
341
+ : placeKey(m);
342
+ const session = r.session ?? defaultSession;
343
+ const chatId = r.chatId ?? m.chat_id;
344
+ const sameTarget = chatId === m.chat_id;
345
+ const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
346
+ const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
347
+ const queueReplyTo = sameTarget ? m.message_id : undefined;
348
+ // Explicit user stop: a control action, never a turn — it must not queue behind the run it
349
+ // stops. Mentions arrive as @name tokens; strip them before matching the bare word. Record the
350
+ // message id so a platform re-push doesn't double-abort or double-notify.
351
+ if (isStopText(normalized.content.text.replace(/@\S+/g, " "))) {
352
+ seen.add(m.message_id);
353
+ sideTasks.track(dispatchStop(control, session, label)
354
+ .then((feedback) => api.sendText({ chatId, replyTo, replyInThread }, feedback).then(() => undefined))
355
+ .catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
356
+ return;
294
357
  }
295
- // ── The console's URL-verification challenge (fires when the operator saves the Request URL). ──
296
- if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
297
- // The console fires this when the operator saves the Request URL; without this line a PASSING
298
- // handshake is invisible and "did the challenge even arrive?" becomes guesswork.
299
- log.info(`${label} answered the console's url_verification challenge`);
300
- return Response.json({ challenge: envelope.challenge });
358
+ const resources = normalized.content.resources;
359
+ const images = resources
360
+ .filter((resource) => resource.kind === "image")
361
+ .map((resource) => ({ msg: resource.messageId, key: resource.key }));
362
+ const files = resources
363
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
364
+ .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
365
+ const baseText = r.text ?? cloudEnvelope(event, kind);
366
+ if (baseText.trim() === "" && images.length === 0 && files.length === 0)
367
+ return;
368
+ if (route === undefined && threadedGroup && m.thread_id === undefined && sameTarget && replyInThread === true) {
369
+ ownedThreads.add(m.chat_id, m.message_id);
301
370
  }
302
- // ── Events. Only im.message.receive_v1 is consumed; everything else is ACKed and dropped
303
- // (a non-2xx would just make the platform retry an event this channel will never act on). ──────
304
- const header = envelope.header;
305
- if (header?.event_type !== "im.message.receive_v1") {
306
- log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
307
- return new Response(null, { status: 200 });
371
+ submit({
372
+ id: m.message_id,
373
+ seq: ++seqCounter,
374
+ session,
375
+ baseText,
376
+ bufferKey,
377
+ chatId,
378
+ replyTo,
379
+ queueReplyTo,
380
+ replyInThread,
381
+ parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
382
+ images,
383
+ files,
384
+ }, true);
385
+ };
386
+ return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
387
+ };
388
+ }
389
+ function createFeishuWebhookRoutes(profile, opts, runtime) {
390
+ const { verificationToken, encryptKey } = opts;
391
+ const { kind, envPrefix } = profile;
392
+ const label = `[${kind}]`;
393
+ const handler = async (req) => {
394
+ if (req.method !== "POST")
395
+ return text("POST only\n", 405);
396
+ const body = await readBodyCapped(req, MAX_EVENT_BYTES);
397
+ if ("tooLarge" in body)
398
+ return text("payload too large\n", 413);
399
+ let outer;
400
+ try {
401
+ outer = JSON.parse(body.text);
402
+ if (typeof outer !== "object" || outer === null)
403
+ throw new Error("not an object");
404
+ }
405
+ catch {
406
+ return text("invalid json\n", 400);
407
+ }
408
+ let envelope;
409
+ if (typeof outer.encrypt === "string") {
410
+ if (!encryptKey) {
411
+ log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
412
+ return text("encrypt key not configured\n", 400);
308
413
  }
309
- const event = (envelope.event ?? {});
310
- const m = event.message;
311
- if (!m?.message_id || !m.chat_id)
312
- return new Response(null, { status: 200 });
313
- if (seen.has(m.message_id)) {
314
- log.debug(`${label} duplicate push for message ${m.message_id} already accepted, skipping`);
315
- return new Response(null, { status: 200 });
414
+ const sig = {
415
+ timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
416
+ nonce: req.headers.get("x-lark-request-nonce") ?? "",
417
+ signature: req.headers.get("x-lark-signature") ?? "",
418
+ };
419
+ if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
420
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
421
+ return text("invalid signature\n", 401);
316
422
  }
317
- const r = decide(event);
318
- if (!r) {
319
- log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
320
- return new Response(null, { status: 200 });
423
+ try {
424
+ envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
321
425
  }
322
- {
323
- const session = r.session ?? placeKey(m);
324
- const chatId = r.chatId ?? m.chat_id;
325
- // Reply to the summoning message in groups (threads the answer under the asker; stays inside a
326
- // topic); a 1:1 p2p chat needs no reply-quote. Only when the RESOLVED target is the message's own
327
- // chat: a route that redirects elsewhere must not quote a same-id message in the wrong place.
328
- const sameTarget = chatId === m.chat_id;
329
- const replyTo = m.chat_type !== "p2p" && sameTarget ? m.message_id : undefined;
330
- // Queue feedback always identifies the exact ask, including p2p. Ordinary p2p answers remain
331
- // unquoted unless the turn actually waited long enough for its queue preview to mount.
332
- const queueReplyTo = sameTarget ? m.message_id : undefined;
333
- const content = parseContent(m);
334
- const baseText = r.text ?? cloudEnvelope(event, kind);
335
- if (baseText.trim() !== "" || content.imageKeys.length > 0 || content.fileRefs.length > 0) {
336
- submit({
337
- id: m.message_id,
338
- seq: ++seqCounter,
339
- session,
340
- baseText,
341
- chatId,
342
- replyTo,
343
- queueReplyTo,
344
- replyInThread: replyTo !== undefined && m.thread_id !== undefined ? true : undefined,
345
- parentId: m.parent_id,
346
- images: content.imageKeys.map((key) => ({ msg: m.message_id, key })),
347
- files: content.fileRefs.map((f) => ({ msg: m.message_id, key: f.key, name: f.name })),
348
- }, true);
426
+ catch {
427
+ if (!sig.signature) {
428
+ log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
429
+ return text("invalid encrypted payload\n", 401);
349
430
  }
431
+ return text("invalid encrypted payload\n", 400);
350
432
  }
351
- // ACK immediately (the platform expects a fast 200; the turn may outlast it by minutes)
352
- // lifecycle goes to stderr; after the 200 those lines are the operator's only signal.
433
+ if (!sig.signature && envelope.type !== "url_verification") {
434
+ log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
435
+ return text("invalid signature\n", 401);
436
+ }
437
+ }
438
+ else {
439
+ if (encryptKey) {
440
+ log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
441
+ return text("plaintext events not accepted\n", 401);
442
+ }
443
+ envelope = outer;
444
+ }
445
+ const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
446
+ (typeof envelope.header?.token === "string"
447
+ ? envelope.header.token
448
+ : undefined);
449
+ if (!token || !timingSafeEqualStr(token, verificationToken)) {
450
+ log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
451
+ return text("invalid token\n", 401);
452
+ }
453
+ if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
454
+ log.info(`${label} answered the console's url_verification challenge`);
455
+ return Response.json({ challenge: envelope.challenge });
456
+ }
457
+ const header = envelope.header;
458
+ if (header?.event_type !== "im.message.receive_v1") {
459
+ log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
353
460
  return new Response(null, { status: 200 });
354
- };
355
- // Test/observability seam: await the fire-and-forget turns this handler enqueues (see turn-queue).
356
- handler.turnsIdle = () => queue.idle();
357
- return { [`POST /${kind}`]: handler };
461
+ }
462
+ runtime.acceptEvent((envelope.event ?? {}));
463
+ return new Response(null, { status: 200 });
464
+ };
465
+ handler.turnsIdle = runtime.turnsIdle;
466
+ return { [`POST /${kind}`]: handler };
467
+ }
468
+ export function buildFeishuChannel(profile, opts, factoryName) {
469
+ validateSessionOptions(opts, factoryName);
470
+ const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
471
+ return (ctx) => {
472
+ if (!opts.verificationToken) {
473
+ throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks)`);
474
+ }
475
+ return createFeishuWebhookRoutes(profile, opts, createRuntime(ctx));
476
+ };
477
+ }
478
+ export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
479
+ validateSessionOptions(opts, factoryName);
480
+ const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
481
+ return {
482
+ name: `${profile.kind} websocket`,
483
+ connect(ctx, signal) {
484
+ const runtime = createRuntime(ctx);
485
+ return (deps.connectWs ?? connectFeishuWs)({
486
+ kind: profile.kind,
487
+ appId: opts.appId,
488
+ appSecret: opts.appSecret,
489
+ domain: opts.apiBaseUrl ?? opts.baseUrl ?? profile.apiBase,
490
+ onEvent: runtime.acceptEvent,
491
+ }, signal);
492
+ },
358
493
  };
359
494
  }
@@ -6,13 +6,14 @@
6
6
  * half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
7
7
  * per-turn lifecycle.
8
8
  *
9
- * Everything here is PRIMARY input the summoning message's own attachments and the message the user
10
- * explicitly replied to — so any load failure THROWS and the caller sees a `failed` event: the agent
11
- * never runs on inputs the user pointed at but we failed to load. (The telegram channel's second,
12
- * degrade-per-attachment tier is its context BUFFER background material; Feishu grows that tier only
13
- * with the buffer itself, which needs the sensitive all-group-messages scope.)
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.
14
13
  */
15
- 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";
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. */
18
19
  export interface FeishuTurnTransport {
@@ -23,37 +24,29 @@ 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;
30
31
  }
31
- /** A turn's attachment inputs: the summoning message's own resources, plus the message it replied to
32
- * (resolved here — content and resources both). */
32
+ /** A turn's primary resources plus background resources folded from the context buffer. */
33
33
  export interface FeishuTurnAttachments {
34
- images: FeishuAttachmentInput[];
35
- files: FeishuAttachmentInput[];
36
- /** The replied-to message's id, when the summon is a reply. */
37
- parentId?: string;
38
- }
39
- /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
40
- * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
41
- * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
42
- * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
43
- export interface BusyRetry {
44
- delayMs: number;
45
- maxWaitMs: number;
34
+ primary: {
35
+ images: FeishuAttachmentInput[];
36
+ files: FeishuAttachmentInput[];
37
+ /** The replied-to message's id, when the summon is a reply. */
38
+ parentId?: string;
39
+ };
40
+ buffered: {
41
+ files: FeishuBufferedRef[];
42
+ images: FeishuBufferedRef[];
43
+ skipped: number;
44
+ };
46
45
  }
47
46
  /**
48
- * Run one turn: resolve its inputs, then stream agent.invoke. An input failure surfaces as a `failed`
49
- * event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed` event the
50
- * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
51
- * moment the turn provably lives in the session.
52
- *
53
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
54
- * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
55
- * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
56
- * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
57
- * 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).
58
50
  */
59
51
  export declare function invokeFeishuTurn(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
52
+ export {};