eve-lark 0.4.5 → 0.5.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.
package/dist/index.js CHANGED
@@ -107,22 +107,24 @@ var LarkClient = class {
107
107
  }
108
108
  async sendText(args) {
109
109
  const content = JSON.stringify({ text: args.content });
110
+ if (args.rootId) {
111
+ return this.#replyMessage(args.rootId, "text", content);
112
+ }
110
113
  return this.#sendMessage({
111
114
  receive_id: args.chatId,
112
115
  msg_type: "text",
113
- content,
114
- root_id: args.rootId,
115
- parent_id: args.parentId
116
+ content
116
117
  });
117
118
  }
118
119
  async sendCard(args) {
119
120
  const content = JSON.stringify(args.card);
121
+ if (args.rootId) {
122
+ return this.#replyMessage(args.rootId, "interactive", content);
123
+ }
120
124
  return this.#sendMessage({
121
125
  receive_id: args.chatId,
122
126
  msg_type: "interactive",
123
- content,
124
- root_id: args.rootId,
125
- parent_id: args.parentId
127
+ content
126
128
  });
127
129
  }
128
130
  async sendPost(args) {
@@ -131,14 +133,31 @@ var LarkClient = class {
131
133
  content: [[{ tag: "md", text: args.content }]]
132
134
  }
133
135
  };
136
+ const content = JSON.stringify(post);
137
+ if (args.rootId) {
138
+ return this.#replyMessage(args.rootId, "post", content);
139
+ }
134
140
  return this.#sendMessage({
135
141
  receive_id: args.chatId,
136
142
  msg_type: "post",
137
- content: JSON.stringify(post),
138
- root_id: args.rootId,
139
- parent_id: args.parentId
143
+ content
140
144
  });
141
145
  }
146
+ /** Quote-reply to a specific message via Feishu's reply API.
147
+ * POST /open-apis/im/v1/messages/{message_id}/reply — this is the only
148
+ * way to quote-reply to a normal (non-thread) message; sendMessage's
149
+ * root_id field only works inside threads. */
150
+ async #replyMessage(replyToMessageId, msgType, content) {
151
+ const path = `/open-apis/im/v1/messages/${encodeURIComponent(replyToMessageId)}/reply`;
152
+ const json = await this.#request("POST", path, { msg_type: msgType, content });
153
+ const messageId = json.data?.message_id;
154
+ if (!messageId) {
155
+ throw new LarkApiError("eve-lark: reply missing message_id in response", {
156
+ body: json
157
+ });
158
+ }
159
+ return { messageId };
160
+ }
142
161
  async #sendMessage(body) {
143
162
  const payload = Object.fromEntries(
144
163
  Object.entries(body).filter(([, v]) => v !== void 0)
@@ -885,6 +904,16 @@ var StreamingCardController = class {
885
904
  getMessageId() {
886
905
  return this.messageId;
887
906
  }
907
+ /** Aim the next card we create at this user message (quote-reply). Used
908
+ * when messages interleave so the user can tell which reply answers which
909
+ * message. Must be called before the first sendCard (i.e. in the webhook
910
+ * handler right after helpers.send, before any streaming delta).
911
+ *
912
+ * Sets rootId (not parentId) because Feishu's sendMessage API uses root_id
913
+ * for quote-reply; parent_id is ignored. */
914
+ setReplyTarget(messageId) {
915
+ this.deps.rootId = messageId;
916
+ }
888
917
  /** Active HITL request being rendered inline on this card, or null. */
889
918
  getAskRequest() {
890
919
  return this.askRequest;
@@ -946,10 +975,11 @@ var StreamingCardController = class {
946
975
  * patch the first card — the user would only see the latest reply
947
976
  * overwritten onto one card instead of N independent cards.
948
977
  */
949
- resetForNewMessage() {
978
+ resetForNewMessage(replyTargetMessageId) {
950
979
  this.resetForNewTurn();
951
980
  this.messageId = void 0;
952
981
  this.state = "idle";
982
+ if (replyTargetMessageId) this.deps.rootId = replyTargetMessageId;
953
983
  }
954
984
  async finalize(fullText) {
955
985
  if (this.state === "completed" || this.state === "aborted") return;
@@ -1032,9 +1062,10 @@ var StreamingCardController = class {
1032
1062
  scheduleCreate() {
1033
1063
  if (this.createTimer) return;
1034
1064
  this.state = "creating";
1065
+ const rootIdSnapshot = this.deps.rootId;
1035
1066
  this.createTimer = setTimeout(() => {
1036
1067
  this.createTimer = null;
1037
- void this.doCreate();
1068
+ void this.doCreate(rootIdSnapshot);
1038
1069
  }, this.deps.createThresholdMs);
1039
1070
  }
1040
1071
  cancelCreateTimer() {
@@ -1043,13 +1074,13 @@ var StreamingCardController = class {
1043
1074
  this.createTimer = null;
1044
1075
  }
1045
1076
  }
1046
- async doCreate() {
1077
+ async doCreate(rootIdSnapshot) {
1047
1078
  if (this.state !== "creating") return;
1048
1079
  try {
1049
1080
  const res = await this.client.sendCard({
1050
1081
  chatId: this.deps.chatId,
1051
1082
  card: this.deps.useCardKitV2 ? buildCardKitStreamingCard({ buffer: this.buffer, status: this.status, streamingMode: true, toolCalls: this.toolCalls, askRequest: this.askRequest }) : buildStreamingCard({ buffer: this.buffer, status: this.status, toolCalls: this.toolCalls, askRequest: this.askRequest }),
1052
- rootId: this.deps.rootId,
1083
+ rootId: rootIdSnapshot ?? this.deps.rootId,
1053
1084
  parentId: this.deps.parentId
1054
1085
  });
1055
1086
  this.messageId = res.messageId;
@@ -1104,12 +1135,11 @@ var StreamingCardController = class {
1104
1135
  var DEFAULTS = {
1105
1136
  baseUrl: "https://open.feishu.cn",
1106
1137
  webhookPath: "/lark/webhook",
1107
- // "post" renders at native chat-message size with full markdown support
1108
- // (bold, links, code, color tags). Cards render noticeably smaller because
1109
- // Feishu treats them as "structured content". The tradeoff: post can't be
1110
- // live-patched during streaming users who want streaming should set
1111
- // replyMode: "streaming" explicitly.
1112
- replyMode: "post",
1138
+ // "streaming-v2" uses Feishu CardKit v2 (schema 2.0 + streaming_mode) for
1139
+ // live-patched interactive cards the best live UX this channel can offer
1140
+ // today. Users who prefer native chat-message size (with markdown) at the
1141
+ // cost of no streaming should opt into replyMode: "post".
1142
+ replyMode: "streaming-v2",
1113
1143
  streamPatchIntervalMs: 1e3,
1114
1144
  streamCreateThresholdMs: 400,
1115
1145
  dedupTtlMs: 30 * 60 * 1e3,
@@ -1712,13 +1742,18 @@ function createLarkChannel(optionsInput) {
1712
1742
  console.log("[eve-lark] skipping WSClient start in eve-start launcher process; the spawned server will start it");
1713
1743
  }
1714
1744
  const controllers = /* @__PURE__ */ new Map();
1745
+ const turnSources = /* @__PURE__ */ new Map();
1746
+ const turnTouchedAt = /* @__PURE__ */ new Map();
1715
1747
  const sessionMeta = /* @__PURE__ */ new Map();
1716
1748
  const pendingInputsByRequestId = /* @__PURE__ */ new Map();
1717
1749
  const pendingInputsByChatToken = /* @__PURE__ */ new Map();
1718
1750
  const authCards = /* @__PURE__ */ new Map();
1719
- const expectFreshCard = /* @__PURE__ */ new Set();
1720
- function getController(sessionId, meta) {
1721
- let ctrl = controllers.get(sessionId);
1751
+ const ackReactions = /* @__PURE__ */ new Map();
1752
+ const currentInboundMsgIds = /* @__PURE__ */ new Map();
1753
+ const lastChatMessage = /* @__PURE__ */ new Map();
1754
+ const cleanedTurns = /* @__PURE__ */ new Set();
1755
+ function getController(turnId, meta) {
1756
+ let ctrl = controllers.get(turnId);
1722
1757
  if (!ctrl) {
1723
1758
  ctrl = new StreamingCardController(client, {
1724
1759
  chatId: meta.chatId,
@@ -1728,39 +1763,48 @@ function createLarkChannel(optionsInput) {
1728
1763
  createThresholdMs: options.streamCreateThresholdMs,
1729
1764
  useCardKitV2: options.replyMode === "streaming-v2"
1730
1765
  });
1731
- controllers.set(sessionId, ctrl);
1732
- }
1733
- if (sessionMeta.has(sessionId)) {
1734
- sessionMeta.get(sessionId).touchedAt = Date.now();
1735
- } else {
1736
- sessionMeta.set(sessionId, {
1737
- chatId: meta.chatId,
1738
- rootId: meta.rootId,
1739
- parentId: meta.parentId,
1740
- messageId: meta.messageId,
1741
- touchedAt: Date.now()
1742
- });
1766
+ controllers.set(turnId, ctrl);
1743
1767
  }
1768
+ turnTouchedAt.set(turnId, Date.now());
1744
1769
  return ctrl;
1745
1770
  }
1746
1771
  __name(getController, "getController");
1747
- async function cleanupAckReaction(sessionId) {
1748
- const meta = sessionMeta.get(sessionId);
1749
- if (!meta?.ackReactionId || !meta.messageId) return;
1772
+ function dropTurn(turnId) {
1773
+ controllers.delete(turnId);
1774
+ turnSources.delete(turnId);
1775
+ turnTouchedAt.delete(turnId);
1776
+ }
1777
+ __name(dropTurn, "dropTurn");
1778
+ async function cleanupAckReaction(messageId) {
1779
+ const ack = ackReactions.get(messageId);
1780
+ if (!ack) return;
1750
1781
  try {
1751
- await client.removeReaction({
1752
- messageId: meta.messageId,
1753
- reactionId: meta.ackReactionId
1754
- });
1782
+ await client.removeReaction({ messageId, reactionId: ack.reactionId });
1755
1783
  } catch (e) {
1756
1784
  console.warn(
1757
1785
  "[eve-lark] ack reaction cleanup failed:",
1758
1786
  e instanceof Error ? e.message : e
1759
1787
  );
1760
1788
  }
1789
+ ackReactions.delete(messageId);
1761
1790
  }
1762
1791
  __name(cleanupAckReaction, "cleanupAckReaction");
1763
- async function deliverReply(sessionId, info, text) {
1792
+ async function cleanupAckForSession(sessionId) {
1793
+ const queue = currentInboundMsgIds.get(sessionId);
1794
+ if (!queue || queue.length === 0) return;
1795
+ const msgId = queue.shift();
1796
+ if (!msgId) return;
1797
+ if (queue.length === 0) currentInboundMsgIds.delete(sessionId);
1798
+ await cleanupAckReaction(msgId);
1799
+ }
1800
+ __name(cleanupAckForSession, "cleanupAckForSession");
1801
+ async function cleanupAckForTurn(turnId) {
1802
+ const sourceMsgId = turnSources.get(turnId);
1803
+ if (!sourceMsgId) return;
1804
+ await cleanupAckReaction(sourceMsgId);
1805
+ }
1806
+ __name(cleanupAckForTurn, "cleanupAckForTurn");
1807
+ async function deliverReply(turnId, info, text) {
1764
1808
  if (options.replyMode === "post") {
1765
1809
  try {
1766
1810
  await client.sendPost({
@@ -1769,11 +1813,11 @@ function createLarkChannel(optionsInput) {
1769
1813
  rootId: info.rootId,
1770
1814
  parentId: info.parentId
1771
1815
  });
1772
- console.log(`[eve-lark] delivered via sendPost (sessionId=${sessionId})`);
1816
+ console.log(`[eve-lark] delivered via sendPost (turnId=${turnId ?? "?"})`);
1773
1817
  return;
1774
1818
  } catch (postErr) {
1775
1819
  console.warn(
1776
- `[eve-lark] sendPost failed; falling back to plain text (sessionId=${sessionId}):`,
1820
+ `[eve-lark] sendPost failed; falling back to plain text (turnId=${turnId ?? "?"}):`,
1777
1821
  postErr instanceof Error ? postErr.message : postErr
1778
1822
  );
1779
1823
  }
@@ -1784,40 +1828,43 @@ function createLarkChannel(optionsInput) {
1784
1828
  rootId: info.rootId,
1785
1829
  parentId: info.parentId
1786
1830
  });
1787
- console.log(`[eve-lark] delivered via sendText fallback (sessionId=${sessionId})`);
1831
+ console.log(`[eve-lark] delivered via sendText fallback (turnId=${turnId ?? "?"})`);
1788
1832
  } catch (textErr) {
1789
1833
  console.error(
1790
- `[eve-lark] sendText fallback ALSO failed; the user will not see this reply (sessionId=${sessionId}):`,
1834
+ `[eve-lark] sendText fallback ALSO failed; the user will not see this reply (turnId=${turnId ?? "?"}):`,
1791
1835
  textErr instanceof Error ? textErr.message : textErr
1792
1836
  );
1793
1837
  }
1794
1838
  return;
1795
1839
  }
1796
1840
  if (options.replyMode === "streaming" || options.replyMode === "streaming-v2") {
1797
- const ctrl = controllers.get(sessionId) ?? getController(sessionId, info);
1798
- try {
1799
- await ctrl.finalize(text);
1800
- console.log(`[eve-lark] delivered via streaming finalize (sessionId=${sessionId})`);
1801
- return;
1802
- } catch (e) {
1803
- console.warn(
1804
- `[eve-lark] streaming finalize failed; falling back to fresh card (sessionId=${sessionId}):`,
1805
- e instanceof Error ? e.message : e
1806
- );
1841
+ const ctrl = turnId ? controllers.get(turnId) : void 0;
1842
+ if (ctrl) {
1843
+ try {
1844
+ await ctrl.finalize(text);
1845
+ console.log(`[eve-lark] delivered via streaming finalize (turnId=${turnId})`);
1846
+ return;
1847
+ } catch (e) {
1848
+ console.warn(
1849
+ `[eve-lark] streaming finalize failed; falling back to fresh card (turnId=${turnId}):`,
1850
+ e instanceof Error ? e.message : e
1851
+ );
1852
+ }
1807
1853
  }
1808
1854
  }
1809
1855
  try {
1810
- await client.sendCard({
1856
+ const res = await client.sendCard({
1811
1857
  chatId: info.chatId,
1812
1858
  card: buildTextCard(text),
1813
1859
  rootId: info.rootId,
1814
1860
  parentId: info.parentId
1815
1861
  });
1816
- console.log(`[eve-lark] delivered via sendCard (sessionId=${sessionId})`);
1862
+ console.log(`[eve-lark] delivered via sendCard (turnId=${turnId ?? "?"})`);
1863
+ void res;
1817
1864
  return;
1818
1865
  } catch (cardErr) {
1819
1866
  console.warn(
1820
- `[eve-lark] sendCard failed; falling back to plain text (sessionId=${sessionId}):`,
1867
+ `[eve-lark] sendCard failed; falling back to plain text (turnId=${turnId ?? "?"}):`,
1821
1868
  cardErr instanceof Error ? cardErr.message : cardErr
1822
1869
  );
1823
1870
  }
@@ -1828,10 +1875,10 @@ function createLarkChannel(optionsInput) {
1828
1875
  rootId: info.rootId,
1829
1876
  parentId: info.parentId
1830
1877
  });
1831
- console.log(`[eve-lark] delivered via sendText fallback (sessionId=${sessionId})`);
1878
+ console.log(`[eve-lark] delivered via sendText fallback (turnId=${turnId ?? "?"})`);
1832
1879
  } catch (textErr) {
1833
1880
  console.error(
1834
- `[eve-lark] sendText fallback ALSO failed; the user will not see this reply (sessionId=${sessionId}):`,
1881
+ `[eve-lark] sendText fallback ALSO failed; the user will not see this reply (turnId=${turnId ?? "?"}):`,
1835
1882
  textErr instanceof Error ? textErr.message : textErr
1836
1883
  );
1837
1884
  }
@@ -1843,9 +1890,13 @@ function createLarkChannel(optionsInput) {
1843
1890
  if (now - lastSweepAt < SWEEP_INTERVAL_MS) return;
1844
1891
  lastSweepAt = now;
1845
1892
  const cutoff = now - STALE_SESSION_MS;
1893
+ for (const [turnId, touchedAt] of turnTouchedAt) {
1894
+ if (touchedAt < cutoff) {
1895
+ dropTurn(turnId);
1896
+ }
1897
+ }
1846
1898
  for (const [id, meta] of sessionMeta) {
1847
1899
  if (meta.touchedAt < cutoff) {
1848
- controllers.delete(id);
1849
1900
  sessionMeta.delete(id);
1850
1901
  }
1851
1902
  }
@@ -2070,21 +2121,22 @@ function createLarkChannel(optionsInput) {
2070
2121
  console.log(
2071
2122
  `[eve-lark] helpers.send returned sessionId=${session.id} for chatId=${parsed.chatId}`
2072
2123
  );
2073
- expectFreshCard.add(session.id);
2124
+ const queue = currentInboundMsgIds.get(session.id);
2125
+ if (queue) queue.push(parsed.messageId);
2126
+ else currentInboundMsgIds.set(session.id, [parsed.messageId]);
2127
+ lastChatMessage.set(session.id, parsed.messageId);
2074
2128
  sessionMeta.set(session.id, {
2075
2129
  chatId: parsed.chatId,
2076
2130
  rootId: parsed.rootId ?? void 0,
2077
2131
  parentId: parsed.parentId ?? void 0,
2078
- messageId: parsed.messageId,
2079
2132
  touchedAt: Date.now()
2080
2133
  });
2081
2134
  const emoji = pickAckEmoji(options.ackReaction);
2082
2135
  if (emoji) {
2083
- const sessionId = session.id;
2136
+ const inboundMsgId = parsed.messageId;
2084
2137
  helpers.waitUntil(
2085
- client.addReaction({ messageId: parsed.messageId, emojiType: emoji }).then(({ reactionId }) => {
2086
- const m = sessionMeta.get(sessionId);
2087
- if (m) m.ackReactionId = reactionId;
2138
+ client.addReaction({ messageId: inboundMsgId, emojiType: emoji }).then(({ reactionId }) => {
2139
+ ackReactions.set(inboundMsgId, { reactionId, createdAt: Date.now() });
2088
2140
  }).catch((e) => {
2089
2141
  console.warn(
2090
2142
  "[eve-lark] ack reaction failed:",
@@ -2114,7 +2166,7 @@ function createLarkChannel(optionsInput) {
2114
2166
  helpers.waitUntil(
2115
2167
  (async () => {
2116
2168
  if (pending.cardMessageId && selectedOpt) {
2117
- const ctrlForBuffer = controllers.get(pending.sessionId);
2169
+ const ctrlForBuffer = controllers.get(pending.turnId);
2118
2170
  const priorBuffer = ctrlForBuffer?.getBuffer() ?? void 0;
2119
2171
  try {
2120
2172
  await client.patchCard({
@@ -2180,9 +2232,16 @@ function createLarkChannel(optionsInput) {
2180
2232
  const sessionId = ctx.session.id;
2181
2233
  const info = sessionInfoFromCtx(ctx);
2182
2234
  if (!info) return;
2235
+ const turnId = data?.turnId;
2236
+ if (!turnId) return;
2183
2237
  const d = data;
2184
2238
  if (typeof d.messageDelta !== "string") return;
2185
- const ctrl = getController(sessionId, info);
2239
+ const ctrl = getController(turnId, info);
2240
+ const source = turnSources.get(turnId);
2241
+ if (source) {
2242
+ console.log(`[eve-lark] quote-reply turnId=${turnId} sessionId=${sessionId} \u2192 REPLY ${source}`);
2243
+ ctrl.setReplyTarget(source);
2244
+ }
2186
2245
  ctrl.appendDelta(d.messageDelta);
2187
2246
  },
2188
2247
  // Model is about to call tools. Record each call on the streaming
@@ -2194,13 +2253,14 @@ function createLarkChannel(optionsInput) {
2194
2253
  // surface to update.
2195
2254
  async "actions.requested"(data, _channel, ctx) {
2196
2255
  if (options.replyMode !== "streaming" && options.replyMode !== "streaming-v2") return;
2197
- const sessionId = ctx.session.id;
2198
2256
  const info = sessionInfoFromCtx(ctx);
2199
2257
  if (!info) return;
2258
+ const turnId = data?.turnId;
2259
+ if (!turnId) return;
2200
2260
  const d = data;
2201
2261
  const names = (d.actions ?? []).map((a) => a.toolName).filter((n) => typeof n === "string");
2202
2262
  if (names.length === 0) return;
2203
- const ctrl = getController(sessionId, info);
2263
+ const ctrl = getController(turnId, info);
2204
2264
  for (const name of names) {
2205
2265
  ctrl.addToolCall(name);
2206
2266
  }
@@ -2209,10 +2269,11 @@ function createLarkChannel(optionsInput) {
2209
2269
  // the user keeps the tool history at the top of the card through end
2210
2270
  // of turn. Best-effort: if we can't find the controller (e.g. post
2211
2271
  // mode, or session already cleaned up), no-op.
2212
- async "action.result"(data, _channel, ctx) {
2272
+ async "action.result"(data, _channel, _ctx) {
2213
2273
  if (options.replyMode !== "streaming" && options.replyMode !== "streaming-v2") return;
2214
- const sessionId = ctx.session.id;
2215
- const ctrl = controllers.get(sessionId);
2274
+ const turnId = data?.turnId;
2275
+ if (!turnId) return;
2276
+ const ctrl = controllers.get(turnId);
2216
2277
  if (!ctrl) return;
2217
2278
  const d = data;
2218
2279
  const name = d.result?.toolName;
@@ -2229,14 +2290,19 @@ function createLarkChannel(optionsInput) {
2229
2290
  console.warn(`[eve-lark] input.requested: no session info (sessionId=${sessionId})`);
2230
2291
  return;
2231
2292
  }
2293
+ const turnId = data?.turnId;
2294
+ if (!turnId) {
2295
+ console.warn(`[eve-lark] input.requested: no turnId (sessionId=${sessionId})`);
2296
+ return;
2297
+ }
2232
2298
  const d = data;
2233
2299
  const requests = d.requests ?? [];
2234
2300
  if (requests.length === 0) return;
2235
2301
  console.log(
2236
- `[eve-lark] input.requested sessionId=${sessionId} chatId=${info.chatId} count=${requests.length}`
2302
+ `[eve-lark] input.requested sessionId=${sessionId} turnId=${turnId} chatId=${info.chatId} count=${requests.length}`
2237
2303
  );
2238
2304
  for (const req of requests) {
2239
- const existingCtrl = controllers.get(sessionId);
2305
+ const existingCtrl = controllers.get(turnId);
2240
2306
  const canPatchExisting = existingCtrl && existingCtrl.getMessageId() && (options.replyMode === "streaming" || options.replyMode === "streaming-v2");
2241
2307
  let cardMessageId;
2242
2308
  if (canPatchExisting && existingCtrl) {
@@ -2263,6 +2329,7 @@ function createLarkChannel(optionsInput) {
2263
2329
  const pending = {
2264
2330
  requestId: req.requestId,
2265
2331
  sessionId,
2332
+ turnId,
2266
2333
  chatId: info.chatId,
2267
2334
  rootId: info.rootId,
2268
2335
  parentId: info.parentId,
@@ -2280,6 +2347,13 @@ function createLarkChannel(optionsInput) {
2280
2347
  }
2281
2348
  },
2282
2349
  // Terminal — deliver the final reply, then clean up the ack reaction.
2350
+ // The per-turn controller is NOT destroyed here: in HITL flows eve fires
2351
+ // input.requested AFTER message.completed (still in the same turn), and
2352
+ // that handler needs the controller to patch the streaming card with the
2353
+ // ask UI inline. The controller is torn down by turn.completed (the true
2354
+ // end of turn). Duplicate message.completed events on the same turn are
2355
+ // deduped by the controller's finalize() state guard (state→completed on
2356
+ // first call, no-op thereafter).
2283
2357
  async "message.completed"(data, _channel, ctx) {
2284
2358
  const sessionId = ctx.session.id;
2285
2359
  const info = sessionInfoFromCtx(ctx);
@@ -2289,14 +2363,28 @@ function createLarkChannel(optionsInput) {
2289
2363
  }
2290
2364
  const d = data;
2291
2365
  const rawText = typeof d.message === "string" ? d.message : "";
2366
+ const mcTurnId = data?.turnId;
2292
2367
  console.log(
2293
- `[eve-lark] message.completed sessionId=${sessionId} chatId=${info.chatId} msgLen=${rawText.length}`
2368
+ `[eve-lark] message.completed sessionId=${sessionId} chatId=${info.chatId} msgLen=${rawText.length}` + (mcTurnId ? ` turnId=${mcTurnId}` : "")
2294
2369
  );
2295
2370
  const text = rawText.length > 0 ? rawText : EMPTY_REPLY_TEXT;
2371
+ const isStreaming = options.replyMode === "streaming" || options.replyMode === "streaming-v2";
2372
+ if (isStreaming && mcTurnId && !controllers.has(mcTurnId)) {
2373
+ if (!cleanedTurns.has(mcTurnId)) {
2374
+ cleanedTurns.add(mcTurnId);
2375
+ await cleanupAckForTurn(mcTurnId);
2376
+ }
2377
+ return;
2378
+ }
2296
2379
  try {
2297
- await deliverReply(sessionId, info, text);
2380
+ await deliverReply(mcTurnId, info, text);
2298
2381
  } finally {
2299
- await cleanupAckReaction(sessionId);
2382
+ if (mcTurnId && !cleanedTurns.has(mcTurnId)) {
2383
+ cleanedTurns.add(mcTurnId);
2384
+ await cleanupAckForTurn(mcTurnId);
2385
+ } else if (!mcTurnId) {
2386
+ await cleanupAckForSession(sessionId);
2387
+ }
2300
2388
  }
2301
2389
  },
2302
2390
  async "turn.failed"(data, _channel, ctx) {
@@ -2310,33 +2398,39 @@ function createLarkChannel(optionsInput) {
2310
2398
  console.warn(`[eve-lark] turn.failed: no session info (sessionId=${sessionId})`);
2311
2399
  return;
2312
2400
  }
2401
+ const failedTurnId = data?.turnId;
2313
2402
  const userText = formatFailureMessage(data, "turn failed", { sentence: "turn" });
2314
2403
  const errorId = extractErrorId(data?.details);
2315
2404
  console.warn(
2316
- `[eve-lark] turn.failed sessionId=${sessionId} chatId=${info.chatId} err="${userText.slice(0, 200)}"` + (errorId ? ` errorId=${errorId}` : "")
2405
+ `[eve-lark] turn.failed sessionId=${sessionId} chatId=${info.chatId} err="${userText.slice(0, 200)}"` + (failedTurnId ? ` turnId=${failedTurnId}` : "") + (errorId ? ` errorId=${errorId}` : "")
2317
2406
  );
2318
- const ctrl = controllers.get(sessionId);
2407
+ const ctrl = failedTurnId ? controllers.get(failedTurnId) : void 0;
2319
2408
  if (ctrl) {
2320
2409
  try {
2321
2410
  await ctrl.abort(userText);
2322
- console.log(`[eve-lark] error shown via streaming abort (sessionId=${sessionId})`);
2411
+ console.log(`[eve-lark] error shown via streaming abort (turnId=${failedTurnId})`);
2323
2412
  } catch (e) {
2324
2413
  console.warn(
2325
- `[eve-lark] turn.failed: streaming abort failed, will deliver fresh error (sessionId=${sessionId}):`,
2414
+ `[eve-lark] turn.failed: streaming abort failed, will deliver fresh error (turnId=${failedTurnId}):`,
2326
2415
  e instanceof Error ? e.message : e
2327
2416
  );
2328
2417
  try {
2329
- await deliverReply(sessionId, info, userText);
2418
+ await deliverReply(failedTurnId, info, userText);
2330
2419
  } catch {
2331
2420
  }
2332
2421
  }
2333
2422
  } else {
2334
2423
  try {
2335
- await deliverReply(sessionId, info, userText);
2424
+ await deliverReply(failedTurnId, info, userText);
2336
2425
  } catch {
2337
2426
  }
2338
2427
  }
2339
- await cleanupAckReaction(sessionId);
2428
+ if (!failedTurnId || !cleanedTurns.has(failedTurnId)) {
2429
+ if (failedTurnId) cleanedTurns.add(failedTurnId);
2430
+ if (failedTurnId) await cleanupAckForTurn(failedTurnId);
2431
+ else await cleanupAckForSession(sessionId);
2432
+ }
2433
+ if (failedTurnId) dropTurn(failedTurnId);
2340
2434
  },
2341
2435
  async "session.failed"(data) {
2342
2436
  const userText = formatFailureMessage(data, "session failed", { sentence: "session" });
@@ -2345,36 +2439,44 @@ function createLarkChannel(optionsInput) {
2345
2439
  `[eve-lark] session.failed: ${userText}` + (errorId ? ` (errorId=${errorId})` : "")
2346
2440
  );
2347
2441
  },
2348
- // A new turn is starting within an existing session. Two cases:
2349
- //
2350
- // 1. NEW inbound message (the webhook handler set `expectFreshCard`):
2351
- // force a fresh card so each user message gets its own reply card.
2352
- // 2. Continuation (HITL answer via button click / freeform, or tool
2353
- // resume): keep the same card so the whole flow stays on one card.
2442
+ // A new turn is starting. Per-turn controllers mean we no longer reset
2443
+ // existing controllers here — each turn gets a fresh one (created lazily
2444
+ // in message.appended / actions.requested). The only side effect left is
2445
+ // mapping turnId inbound source message: shift the head of the
2446
+ // session's FIFO (populated by the webhook handler) so message.appended
2447
+ // can aim this turn's card at the right user message and terminal events
2448
+ // can clean up the right ack reaction.
2354
2449
  async "turn.started"(_data, _channel, ctx) {
2355
2450
  const sessionId = ctx?.session?.id;
2356
2451
  if (!sessionId) return;
2357
- const ctrl = controllers.get(sessionId);
2358
- if (ctrl) {
2359
- if (expectFreshCard.has(sessionId)) {
2360
- ctrl.resetForNewMessage();
2361
- expectFreshCard.delete(sessionId);
2362
- } else {
2363
- ctrl.resetForNewTurn();
2364
- }
2452
+ const tsTurnId = _data?.turnId;
2453
+ console.log(`[eve-lark] turn.started sessionId=${sessionId}` + (tsTurnId ? ` turnId=${tsTurnId}` : ""));
2454
+ if (!tsTurnId) return;
2455
+ const queue = currentInboundMsgIds.get(sessionId);
2456
+ if (queue && queue.length > 0) {
2457
+ const source = queue.shift();
2458
+ if (queue.length === 0) currentInboundMsgIds.delete(sessionId);
2459
+ turnSources.set(tsTurnId, source);
2365
2460
  }
2366
2461
  },
2367
2462
  // Turn ended cleanly. eve fires this after the final message.completed
2368
2463
  // (or instead of it when the assistant step ended in tool-calls with no
2369
- // visible text). Just clean up the ack reaction the controller stays
2370
- // for the next turn (cleaned by stale-sweep if the session goes quiet).
2371
- async "turn.completed"(data, _channel, ctx) {
2372
- const sessionId = ctx?.session?.id;
2373
- if (!sessionId) return;
2464
+ // visible text). Clean up the ack reaction and destroy the per-turn
2465
+ // controller (idempotent message.completed/turn.failed may have done
2466
+ // it already).
2467
+ async "turn.completed"(data, _channel, _ctx) {
2468
+ const turnId = data?.turnId;
2469
+ if (!turnId) return;
2470
+ if (cleanedTurns.has(turnId)) {
2471
+ dropTurn(turnId);
2472
+ return;
2473
+ }
2474
+ cleanedTurns.add(turnId);
2374
2475
  try {
2375
- await cleanupAckReaction(sessionId);
2476
+ await cleanupAckForTurn(turnId);
2376
2477
  } catch {
2377
2478
  }
2479
+ dropTurn(turnId);
2378
2480
  },
2379
2481
  // The agent needs the user to sign in to an external service (e.g.
2380
2482
  // GitHub, Slack, Linear). Render a card with a "Sign in with <X>"