eve-lark 0.4.6 → 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,15 +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
1751
  const ackReactions = /* @__PURE__ */ new Map();
1721
- const currentInboundMsgId = /* @__PURE__ */ new Map();
1722
- function getController(sessionId, meta) {
1723
- let ctrl = controllers.get(sessionId);
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);
1724
1757
  if (!ctrl) {
1725
1758
  ctrl = new StreamingCardController(client, {
1726
1759
  chatId: meta.chatId,
@@ -1730,21 +1763,18 @@ function createLarkChannel(optionsInput) {
1730
1763
  createThresholdMs: options.streamCreateThresholdMs,
1731
1764
  useCardKitV2: options.replyMode === "streaming-v2"
1732
1765
  });
1733
- controllers.set(sessionId, ctrl);
1734
- }
1735
- if (sessionMeta.has(sessionId)) {
1736
- sessionMeta.get(sessionId).touchedAt = Date.now();
1737
- } else {
1738
- sessionMeta.set(sessionId, {
1739
- chatId: meta.chatId,
1740
- rootId: meta.rootId,
1741
- parentId: meta.parentId,
1742
- touchedAt: Date.now()
1743
- });
1766
+ controllers.set(turnId, ctrl);
1744
1767
  }
1768
+ turnTouchedAt.set(turnId, Date.now());
1745
1769
  return ctrl;
1746
1770
  }
1747
1771
  __name(getController, "getController");
1772
+ function dropTurn(turnId) {
1773
+ controllers.delete(turnId);
1774
+ turnSources.delete(turnId);
1775
+ turnTouchedAt.delete(turnId);
1776
+ }
1777
+ __name(dropTurn, "dropTurn");
1748
1778
  async function cleanupAckReaction(messageId) {
1749
1779
  const ack = ackReactions.get(messageId);
1750
1780
  if (!ack) return;
@@ -1760,13 +1790,21 @@ function createLarkChannel(optionsInput) {
1760
1790
  }
1761
1791
  __name(cleanupAckReaction, "cleanupAckReaction");
1762
1792
  async function cleanupAckForSession(sessionId) {
1763
- const msgId = currentInboundMsgId.get(sessionId);
1793
+ const queue = currentInboundMsgIds.get(sessionId);
1794
+ if (!queue || queue.length === 0) return;
1795
+ const msgId = queue.shift();
1764
1796
  if (!msgId) return;
1765
- currentInboundMsgId.delete(sessionId);
1797
+ if (queue.length === 0) currentInboundMsgIds.delete(sessionId);
1766
1798
  await cleanupAckReaction(msgId);
1767
1799
  }
1768
1800
  __name(cleanupAckForSession, "cleanupAckForSession");
1769
- async function deliverReply(sessionId, info, text) {
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) {
1770
1808
  if (options.replyMode === "post") {
1771
1809
  try {
1772
1810
  await client.sendPost({
@@ -1775,11 +1813,11 @@ function createLarkChannel(optionsInput) {
1775
1813
  rootId: info.rootId,
1776
1814
  parentId: info.parentId
1777
1815
  });
1778
- console.log(`[eve-lark] delivered via sendPost (sessionId=${sessionId})`);
1816
+ console.log(`[eve-lark] delivered via sendPost (turnId=${turnId ?? "?"})`);
1779
1817
  return;
1780
1818
  } catch (postErr) {
1781
1819
  console.warn(
1782
- `[eve-lark] sendPost failed; falling back to plain text (sessionId=${sessionId}):`,
1820
+ `[eve-lark] sendPost failed; falling back to plain text (turnId=${turnId ?? "?"}):`,
1783
1821
  postErr instanceof Error ? postErr.message : postErr
1784
1822
  );
1785
1823
  }
@@ -1790,40 +1828,43 @@ function createLarkChannel(optionsInput) {
1790
1828
  rootId: info.rootId,
1791
1829
  parentId: info.parentId
1792
1830
  });
1793
- console.log(`[eve-lark] delivered via sendText fallback (sessionId=${sessionId})`);
1831
+ console.log(`[eve-lark] delivered via sendText fallback (turnId=${turnId ?? "?"})`);
1794
1832
  } catch (textErr) {
1795
1833
  console.error(
1796
- `[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 ?? "?"}):`,
1797
1835
  textErr instanceof Error ? textErr.message : textErr
1798
1836
  );
1799
1837
  }
1800
1838
  return;
1801
1839
  }
1802
1840
  if (options.replyMode === "streaming" || options.replyMode === "streaming-v2") {
1803
- const ctrl = controllers.get(sessionId) ?? getController(sessionId, info);
1804
- try {
1805
- await ctrl.finalize(text);
1806
- console.log(`[eve-lark] delivered via streaming finalize (sessionId=${sessionId})`);
1807
- return;
1808
- } catch (e) {
1809
- console.warn(
1810
- `[eve-lark] streaming finalize failed; falling back to fresh card (sessionId=${sessionId}):`,
1811
- e instanceof Error ? e.message : e
1812
- );
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
+ }
1813
1853
  }
1814
1854
  }
1815
1855
  try {
1816
- await client.sendCard({
1856
+ const res = await client.sendCard({
1817
1857
  chatId: info.chatId,
1818
1858
  card: buildTextCard(text),
1819
1859
  rootId: info.rootId,
1820
1860
  parentId: info.parentId
1821
1861
  });
1822
- console.log(`[eve-lark] delivered via sendCard (sessionId=${sessionId})`);
1862
+ console.log(`[eve-lark] delivered via sendCard (turnId=${turnId ?? "?"})`);
1863
+ void res;
1823
1864
  return;
1824
1865
  } catch (cardErr) {
1825
1866
  console.warn(
1826
- `[eve-lark] sendCard failed; falling back to plain text (sessionId=${sessionId}):`,
1867
+ `[eve-lark] sendCard failed; falling back to plain text (turnId=${turnId ?? "?"}):`,
1827
1868
  cardErr instanceof Error ? cardErr.message : cardErr
1828
1869
  );
1829
1870
  }
@@ -1834,10 +1875,10 @@ function createLarkChannel(optionsInput) {
1834
1875
  rootId: info.rootId,
1835
1876
  parentId: info.parentId
1836
1877
  });
1837
- console.log(`[eve-lark] delivered via sendText fallback (sessionId=${sessionId})`);
1878
+ console.log(`[eve-lark] delivered via sendText fallback (turnId=${turnId ?? "?"})`);
1838
1879
  } catch (textErr) {
1839
1880
  console.error(
1840
- `[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 ?? "?"}):`,
1841
1882
  textErr instanceof Error ? textErr.message : textErr
1842
1883
  );
1843
1884
  }
@@ -1849,9 +1890,13 @@ function createLarkChannel(optionsInput) {
1849
1890
  if (now - lastSweepAt < SWEEP_INTERVAL_MS) return;
1850
1891
  lastSweepAt = now;
1851
1892
  const cutoff = now - STALE_SESSION_MS;
1893
+ for (const [turnId, touchedAt] of turnTouchedAt) {
1894
+ if (touchedAt < cutoff) {
1895
+ dropTurn(turnId);
1896
+ }
1897
+ }
1852
1898
  for (const [id, meta] of sessionMeta) {
1853
1899
  if (meta.touchedAt < cutoff) {
1854
- controllers.delete(id);
1855
1900
  sessionMeta.delete(id);
1856
1901
  }
1857
1902
  }
@@ -2076,8 +2121,10 @@ function createLarkChannel(optionsInput) {
2076
2121
  console.log(
2077
2122
  `[eve-lark] helpers.send returned sessionId=${session.id} for chatId=${parsed.chatId}`
2078
2123
  );
2079
- expectFreshCard.add(session.id);
2080
- currentInboundMsgId.set(session.id, parsed.messageId);
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);
2081
2128
  sessionMeta.set(session.id, {
2082
2129
  chatId: parsed.chatId,
2083
2130
  rootId: parsed.rootId ?? void 0,
@@ -2119,7 +2166,7 @@ function createLarkChannel(optionsInput) {
2119
2166
  helpers.waitUntil(
2120
2167
  (async () => {
2121
2168
  if (pending.cardMessageId && selectedOpt) {
2122
- const ctrlForBuffer = controllers.get(pending.sessionId);
2169
+ const ctrlForBuffer = controllers.get(pending.turnId);
2123
2170
  const priorBuffer = ctrlForBuffer?.getBuffer() ?? void 0;
2124
2171
  try {
2125
2172
  await client.patchCard({
@@ -2185,9 +2232,16 @@ function createLarkChannel(optionsInput) {
2185
2232
  const sessionId = ctx.session.id;
2186
2233
  const info = sessionInfoFromCtx(ctx);
2187
2234
  if (!info) return;
2235
+ const turnId = data?.turnId;
2236
+ if (!turnId) return;
2188
2237
  const d = data;
2189
2238
  if (typeof d.messageDelta !== "string") return;
2190
- 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
+ }
2191
2245
  ctrl.appendDelta(d.messageDelta);
2192
2246
  },
2193
2247
  // Model is about to call tools. Record each call on the streaming
@@ -2199,13 +2253,14 @@ function createLarkChannel(optionsInput) {
2199
2253
  // surface to update.
2200
2254
  async "actions.requested"(data, _channel, ctx) {
2201
2255
  if (options.replyMode !== "streaming" && options.replyMode !== "streaming-v2") return;
2202
- const sessionId = ctx.session.id;
2203
2256
  const info = sessionInfoFromCtx(ctx);
2204
2257
  if (!info) return;
2258
+ const turnId = data?.turnId;
2259
+ if (!turnId) return;
2205
2260
  const d = data;
2206
2261
  const names = (d.actions ?? []).map((a) => a.toolName).filter((n) => typeof n === "string");
2207
2262
  if (names.length === 0) return;
2208
- const ctrl = getController(sessionId, info);
2263
+ const ctrl = getController(turnId, info);
2209
2264
  for (const name of names) {
2210
2265
  ctrl.addToolCall(name);
2211
2266
  }
@@ -2214,10 +2269,11 @@ function createLarkChannel(optionsInput) {
2214
2269
  // the user keeps the tool history at the top of the card through end
2215
2270
  // of turn. Best-effort: if we can't find the controller (e.g. post
2216
2271
  // mode, or session already cleaned up), no-op.
2217
- async "action.result"(data, _channel, ctx) {
2272
+ async "action.result"(data, _channel, _ctx) {
2218
2273
  if (options.replyMode !== "streaming" && options.replyMode !== "streaming-v2") return;
2219
- const sessionId = ctx.session.id;
2220
- const ctrl = controllers.get(sessionId);
2274
+ const turnId = data?.turnId;
2275
+ if (!turnId) return;
2276
+ const ctrl = controllers.get(turnId);
2221
2277
  if (!ctrl) return;
2222
2278
  const d = data;
2223
2279
  const name = d.result?.toolName;
@@ -2234,14 +2290,19 @@ function createLarkChannel(optionsInput) {
2234
2290
  console.warn(`[eve-lark] input.requested: no session info (sessionId=${sessionId})`);
2235
2291
  return;
2236
2292
  }
2293
+ const turnId = data?.turnId;
2294
+ if (!turnId) {
2295
+ console.warn(`[eve-lark] input.requested: no turnId (sessionId=${sessionId})`);
2296
+ return;
2297
+ }
2237
2298
  const d = data;
2238
2299
  const requests = d.requests ?? [];
2239
2300
  if (requests.length === 0) return;
2240
2301
  console.log(
2241
- `[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}`
2242
2303
  );
2243
2304
  for (const req of requests) {
2244
- const existingCtrl = controllers.get(sessionId);
2305
+ const existingCtrl = controllers.get(turnId);
2245
2306
  const canPatchExisting = existingCtrl && existingCtrl.getMessageId() && (options.replyMode === "streaming" || options.replyMode === "streaming-v2");
2246
2307
  let cardMessageId;
2247
2308
  if (canPatchExisting && existingCtrl) {
@@ -2268,6 +2329,7 @@ function createLarkChannel(optionsInput) {
2268
2329
  const pending = {
2269
2330
  requestId: req.requestId,
2270
2331
  sessionId,
2332
+ turnId,
2271
2333
  chatId: info.chatId,
2272
2334
  rootId: info.rootId,
2273
2335
  parentId: info.parentId,
@@ -2285,6 +2347,13 @@ function createLarkChannel(optionsInput) {
2285
2347
  }
2286
2348
  },
2287
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).
2288
2357
  async "message.completed"(data, _channel, ctx) {
2289
2358
  const sessionId = ctx.session.id;
2290
2359
  const info = sessionInfoFromCtx(ctx);
@@ -2294,14 +2363,28 @@ function createLarkChannel(optionsInput) {
2294
2363
  }
2295
2364
  const d = data;
2296
2365
  const rawText = typeof d.message === "string" ? d.message : "";
2366
+ const mcTurnId = data?.turnId;
2297
2367
  console.log(
2298
- `[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}` : "")
2299
2369
  );
2300
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
+ }
2301
2379
  try {
2302
- await deliverReply(sessionId, info, text);
2380
+ await deliverReply(mcTurnId, info, text);
2303
2381
  } finally {
2304
- await cleanupAckForSession(sessionId);
2382
+ if (mcTurnId && !cleanedTurns.has(mcTurnId)) {
2383
+ cleanedTurns.add(mcTurnId);
2384
+ await cleanupAckForTurn(mcTurnId);
2385
+ } else if (!mcTurnId) {
2386
+ await cleanupAckForSession(sessionId);
2387
+ }
2305
2388
  }
2306
2389
  },
2307
2390
  async "turn.failed"(data, _channel, ctx) {
@@ -2315,33 +2398,39 @@ function createLarkChannel(optionsInput) {
2315
2398
  console.warn(`[eve-lark] turn.failed: no session info (sessionId=${sessionId})`);
2316
2399
  return;
2317
2400
  }
2401
+ const failedTurnId = data?.turnId;
2318
2402
  const userText = formatFailureMessage(data, "turn failed", { sentence: "turn" });
2319
2403
  const errorId = extractErrorId(data?.details);
2320
2404
  console.warn(
2321
- `[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}` : "")
2322
2406
  );
2323
- const ctrl = controllers.get(sessionId);
2407
+ const ctrl = failedTurnId ? controllers.get(failedTurnId) : void 0;
2324
2408
  if (ctrl) {
2325
2409
  try {
2326
2410
  await ctrl.abort(userText);
2327
- console.log(`[eve-lark] error shown via streaming abort (sessionId=${sessionId})`);
2411
+ console.log(`[eve-lark] error shown via streaming abort (turnId=${failedTurnId})`);
2328
2412
  } catch (e) {
2329
2413
  console.warn(
2330
- `[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}):`,
2331
2415
  e instanceof Error ? e.message : e
2332
2416
  );
2333
2417
  try {
2334
- await deliverReply(sessionId, info, userText);
2418
+ await deliverReply(failedTurnId, info, userText);
2335
2419
  } catch {
2336
2420
  }
2337
2421
  }
2338
2422
  } else {
2339
2423
  try {
2340
- await deliverReply(sessionId, info, userText);
2424
+ await deliverReply(failedTurnId, info, userText);
2341
2425
  } catch {
2342
2426
  }
2343
2427
  }
2344
- await cleanupAckForSession(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);
2345
2434
  },
2346
2435
  async "session.failed"(data) {
2347
2436
  const userText = formatFailureMessage(data, "session failed", { sentence: "session" });
@@ -2350,36 +2439,44 @@ function createLarkChannel(optionsInput) {
2350
2439
  `[eve-lark] session.failed: ${userText}` + (errorId ? ` (errorId=${errorId})` : "")
2351
2440
  );
2352
2441
  },
2353
- // A new turn is starting within an existing session. Two cases:
2354
- //
2355
- // 1. NEW inbound message (the webhook handler set `expectFreshCard`):
2356
- // force a fresh card so each user message gets its own reply card.
2357
- // 2. Continuation (HITL answer via button click / freeform, or tool
2358
- // 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.
2359
2449
  async "turn.started"(_data, _channel, ctx) {
2360
2450
  const sessionId = ctx?.session?.id;
2361
2451
  if (!sessionId) return;
2362
- const ctrl = controllers.get(sessionId);
2363
- if (ctrl) {
2364
- if (expectFreshCard.has(sessionId)) {
2365
- ctrl.resetForNewMessage();
2366
- expectFreshCard.delete(sessionId);
2367
- } else {
2368
- ctrl.resetForNewTurn();
2369
- }
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);
2370
2460
  }
2371
2461
  },
2372
2462
  // Turn ended cleanly. eve fires this after the final message.completed
2373
2463
  // (or instead of it when the assistant step ended in tool-calls with no
2374
- // visible text). Just clean up the ack reaction the controller stays
2375
- // for the next turn (cleaned by stale-sweep if the session goes quiet).
2376
- async "turn.completed"(data, _channel, ctx) {
2377
- const sessionId = ctx?.session?.id;
2378
- 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);
2379
2475
  try {
2380
- await cleanupAckForSession(sessionId);
2476
+ await cleanupAckForTurn(turnId);
2381
2477
  } catch {
2382
2478
  }
2479
+ dropTurn(turnId);
2383
2480
  },
2384
2481
  // The agent needs the user to sign in to an external service (e.g.
2385
2482
  // GitHub, Slack, Linear). Render a card with a "Sign in with <X>"