Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions example/parsar/server/feishu.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions example/parsar/tests/feishu.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading