From d9b0604a879d11ca407a9f7fd460ba17c9775509 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 11 Nov 2026 21:41:01 +0800 Subject: [PATCH] Pick a Feishu turn's card by its own inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A steer moved the card the thread had last shown, which is not always the running turn's card, so the turn's result went to its old card and the new card stayed at 处理中. A turn's card is now the newest unmoved card one of its own inputs opened, for moving it on a steer and for writing the result. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/server/feishu.mjs | 21 ++++++++++++------- example/parsar/tests/feishu.test.mjs | 31 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index f41533fb2..240c9f79b 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -838,6 +838,12 @@ export function feishuWorker(store, core, product, makeChannel, configId) { }; // A Turn's files reply to the message it answers. Only files its reply names // reach the chat; every file stays downloadable in the Web conversation. + // A Turn's card is the newest card one of its inputs opened that has not moved. + const turnCard = (thread, entry) => + entry && [entry.kind === "first" ? thread.first_message_id : entry.id, ...(entry.steers || []).map((steer) => steer.id)] + .map((id) => store.get("feishu_outbound", stableId(`${id}:accepted`))) + .filter((card) => card && !card.moved) + .at(-1); const queueMedia = (thread, items, artifacts = [], replyTo = thread.root_id) => { const replies = items .filter((item) => item.type === "message" && item.role === "assistant") @@ -1105,7 +1111,8 @@ export function feishuWorker(store, core, product, makeChannel, configId) { row, "支持的命令:/clear、/stop、/answer、/help。其他消息会交给 Agent。", ); - let steered = false; + // The ledger entry of the running Turn this message steered, if any. + let steered; try { if ( media.files.length && @@ -1192,7 +1199,8 @@ export function feishuWorker(store, core, product, makeChannel, configId) { // A message sent while a Turn runs joins it in Core and keeps its card. if (event.type === "agent.session.input.message") { const delivered = await ledger.deliver(run, event.input, { id: row.id, key: row.id, source: "feishu" }); - steered = delivered.id !== row.id || delivered.base_running === true; + steered = delivered.id !== row.id ? delivered : delivered.base_running === true && + store.list("turns").find((t) => t.session_id === run.id && t.outcome === "running" && t.id !== row.id); } else await core( @@ -1229,7 +1237,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { // A message that steers the running Turn takes over its card, which moves // below that message: the Turn's live card always follows the latest message. else { - const previous = steered && thread.active_outbound_id && store.get("feishu_outbound", thread.active_outbound_id); + const previous = steered && turnCard(thread, steered); progress(previous?.payload || thinking); // Feishu marks every recall in the chat, so the old card shrinks to one line instead. if (previous?.sent_message_id) { @@ -1332,11 +1340,8 @@ export function feishuWorker(store, core, product, makeChannel, configId) { let progress = thread.active_outbound_id && store.get("feishu_outbound", thread.active_outbound_id); // The progress card of the input a Turn answers; an older Turn never takes // the card of a newer message. - const cardFor = (turn) => { - const entry = ledger.entryFor(run.id, turn.id); - const own = entry && store.get("feishu_outbound", stableId(`${entry.kind === "first" ? thread.first_message_id : entry.id}:accepted`)); - return (own?.moved ? undefined : own) || (turn.id === turns.at(-1)?.id ? progress : undefined); - }; + const cardFor = (turn) => + turnCard(thread, ledger.entryFor(run.id, turn.id)) || (turn.id === turns.at(-1)?.id ? progress : undefined); const progressReplyTo = progress?.reply_to || thread.root_id; const questionBase = progress?.sent_message_id ? progress.id : thread.id; if ((progress || session.status === "requires_action") && (["in_progress", "requires_action"].includes(session.status) || recovering) && !unseen.length) { diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index f556c01c9..36c19151a 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -2256,3 +2256,34 @@ test("a Feishu file reaches the workspace through Core's Files API, up to 50 MiB assert.match(file.path, /share\.pptx$/); assert.doesNotMatch(JSON.stringify(f.store.list("feishu_inbound")), /AQEBAQEB/, "the message keeps no file bytes"); }); + +test("a steer moves its Turn's own card, whatever card the thread last showed", async (t) => { + const f = await feishu(t); + const config = await f.api("GET", `/app/feishu/bots/${botId}`); + await f.api("PUT", `/app/feishu/bots/${botId}`, { ...config, direct: true }); + const patched = []; + const patch = f.channel.rawClient.im.message.patch; + f.channel.rawClient.im.message.patch = async (request) => (patched.push(request), patch(request)); + const worker = f.create(); + const dm = { ...f.message, chatType: "p2p", mentionedBot: false }; + worker.receive("cli_0123456789abcdef", dm); + await worker.tick(); + const thread = f.store.list("feishu_threads")[0]; + const session = f.resources.get(f.store.get("sessions", thread.session_id).core_session_id); + session.status = "in_progress"; + session.turns[0].status = "in_progress"; + await f.api.sessions.turns.monitor(); + const first = f.store.list("feishu_outbound").find((row) => row.sent_message_id); + // The thread last showed another card, as after a question or an earlier Turn. + f.store.put("feishu_outbound", { id: "other-card", app_id: thread.app_id, chat_id: thread.chat_id, thread_id: thread.id, state: "delivered", sent_message_id: "om_other", payload: "{}" }); + f.store.put("feishu_threads", { ...f.store.get("feishu_threads", thread.id), active_outbound_id: "other-card" }); + worker.receive("cli_0123456789abcdef", { ...dm, messageId: "steer", content: "补充一句" }); + await worker.tick(); + // The fake channel patches a card under its sent ID with a prefix. + const sentAs = (row) => (request) => request.path.message_id.endsWith(row.sent_message_id); + assert.match(patched.find(sentAs(first)).data.content, /已并入下方回复/, "the Turn's own card moves"); + assert.ok(!patched.some((request) => request.path.message_id === "om_other"), "another card is left alone"); + // The thread's live card is now the one below the steer, where the result goes. + const thread2 = f.store.get("feishu_threads", thread.id); + assert.equal(f.store.get("feishu_outbound", thread2.active_outbound_id).reply_to, "steer"); +});