mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
Plain command replies (menu cards, session/workspace/watch lists, /new,
/status, /compact, /archived, model and preset commands) and Harness
approval prompts were sent as fresh messages keyed only by chat_id, so
in topic groups they landed in the group's default area instead of the
topic the conversation belongs to.
Deliver them as replies to the triggering message, mirroring how stream
answers already thread:
- #sendCard accepts { replyTo } and replies interactively, falling back
to a plain card when the referenced message is gone
- command replies, status text, menu/session/workspace/watch cards,
approval prompts and resolved-question notices all carry the
triggering message id
- batch-input failure notices follow the same rule
- bridge.test.mjs: /new in a topic group must thread the confirmation
text and the menu card to the command message
- lib/index.js: regenerated host bundle
5929 lines
208 KiB
JavaScript
5929 lines
208 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { Readable } from 'node:stream';
|
||
import { mkdirSync, realpathSync } from 'node:fs';
|
||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||
import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-channel.mjs';
|
||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
|
||
import {
|
||
OUTBOUND_ARTIFACT_TOOL,
|
||
OutboundArtifactRegistry,
|
||
createOutboundArtifactTool,
|
||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||
|
||
const PNG_1X1 = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||
'base64',
|
||
);
|
||
|
||
function deferred() {
|
||
let resolve;
|
||
let reject;
|
||
const promise = new Promise((promiseResolve, promiseReject) => {
|
||
resolve = promiseResolve;
|
||
reject = promiseReject;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
async function eventually(predicate, message = 'condition was not met') {
|
||
const deadline = Date.now() + 1_000;
|
||
while (Date.now() < deadline) {
|
||
if (predicate()) return;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
assert.fail(message);
|
||
}
|
||
|
||
function event(messageId, text, overrides = {}) {
|
||
const { senderOpenId = 'ou_user', ...messageOverrides } = overrides;
|
||
return {
|
||
sender: { sender_type: 'user', sender_id: { open_id: senderOpenId } },
|
||
message: {
|
||
message_id: messageId,
|
||
message_type: 'text',
|
||
chat_type: 'p2p',
|
||
chat_id: 'oc_chat',
|
||
content: JSON.stringify({ text }),
|
||
...messageOverrides,
|
||
},
|
||
};
|
||
}
|
||
|
||
function stateFixture(initialSessions = []) {
|
||
const sessions = new Map(initialSessions);
|
||
const seen = new Set();
|
||
return {
|
||
sessions,
|
||
seen,
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: (key) => sessions.get(key) ?? null,
|
||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||
clearSession: async (key) => sessions.delete(key),
|
||
},
|
||
};
|
||
}
|
||
|
||
function bridgeStatus() {
|
||
return {
|
||
messagesReceived: 0,
|
||
messagesReplied: 0,
|
||
messagesRejected: 0,
|
||
lastMessageAt: null,
|
||
lastReplyAt: null,
|
||
lastRejectedAt: null,
|
||
lastError: null,
|
||
};
|
||
}
|
||
|
||
function textClient(sendText) {
|
||
let sequence = 0;
|
||
return {
|
||
im: { v1: { message: { create: async (request) => {
|
||
const outgoing = {
|
||
chatId: request.data.receive_id,
|
||
text: JSON.parse(request.data.content).text,
|
||
};
|
||
await sendText(outgoing);
|
||
sequence += 1;
|
||
return { code: 0, data: { message_id: `om_test_${sequence}` } };
|
||
} } } },
|
||
};
|
||
}
|
||
|
||
test('English Feishu status responses contain no bridge-level Chinese fallbacks', async () => {
|
||
const fixture = stateFixture();
|
||
const textSent = [];
|
||
const cardSent = [];
|
||
const patches = [];
|
||
const harness = {
|
||
ensureRunning: async () => true,
|
||
currentWorkspace: () => null,
|
||
agentPresetSettings: async () => ({
|
||
agentPreset: null,
|
||
agentPresetCatalog: {
|
||
defaultId: null,
|
||
items: [],
|
||
},
|
||
}),
|
||
};
|
||
|
||
setImHostLanguage('en');
|
||
try {
|
||
const textBridge = new FeishuHarnessBridge({
|
||
client: textClient(async (outgoing) => textSent.push(outgoing.text)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await textBridge.accept(event('english-status-text', '/status', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
|
||
const cardBridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async (outgoing) => cardSent.push(outgoing),
|
||
async (request) => patches.push(request),
|
||
),
|
||
channel: {},
|
||
harness,
|
||
state: stateFixture().state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await cardBridge.accept(event('english-status-menu', '/m', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await cardBridge.onCardAction(cardActionEvent('om_card_1', 'status', 'ou_owner'));
|
||
|
||
assert.equal(textSent.length, 1);
|
||
assert.doesNotMatch(textSent[0], /[\u3400-\u9fff]/u);
|
||
assert.equal(cardSent.length, 1);
|
||
assert.equal(patches.length, 1);
|
||
assert.doesNotMatch(patches[0].data.content, /[\u3400-\u9fff]/u);
|
||
} finally {
|
||
setImHostLanguage('zh');
|
||
}
|
||
});
|
||
|
||
test('text new-session waits until the active turn is finished before clearing the binding', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const started = deferred();
|
||
const release = deferred();
|
||
const sent = [];
|
||
const { harness } = activeTurnHarness();
|
||
harness.ask = async () => {
|
||
started.resolve();
|
||
await release.promise;
|
||
return 'task finished';
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (outgoing) => sent.push(outgoing.text)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
const turn = bridge.accept(event('new-active-turn', 'run a task', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await started.promise;
|
||
await bridge.accept(event('new-while-active', '/new', { senderOpenId: 'ou_owner' }));
|
||
assert.equal(fixture.sessions.get('p2p:ou_owner'), 'session-active');
|
||
assert.match(sent.at(-1), /当前任务仍在运行/);
|
||
|
||
release.resolve();
|
||
await turn;
|
||
await bridge.accept(event('new-after-active', '/new', { senderOpenId: 'ou_owner' }));
|
||
assert.equal(fixture.sessions.has('p2p:ou_owner'), false);
|
||
});
|
||
|
||
async function committedArtifact(t, fileName, content, suffix = '') {
|
||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
|
||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||
let nextId = 0;
|
||
const registry = new OutboundArtifactRegistry({
|
||
uuid: () => `${suffix || 'file'}-${++nextId}`,
|
||
});
|
||
t.after(() => registry.clear());
|
||
const agent = {
|
||
session: {
|
||
header: { id: `session-${suffix || 'file'}`, cwd: workspace },
|
||
events: [
|
||
{ type: 'turn/start', data: { turn: 1 } },
|
||
{
|
||
type: 'user/message',
|
||
data: { turn: 1, source: { rpcId: `rpc-${suffix || 'file'}` } },
|
||
},
|
||
],
|
||
},
|
||
};
|
||
const tool = createOutboundArtifactTool({ registry });
|
||
const exec = {
|
||
name: OUTBOUND_ARTIFACT_TOOL,
|
||
callId: `call-${suffix || 'file'}`,
|
||
rootCallId: `call-${suffix || 'file'}`,
|
||
token: Symbol(`call-${suffix || 'file'}`),
|
||
agent,
|
||
};
|
||
await writeFile(join(workspace, fileName), content);
|
||
await tool.definition.execute({ path: fileName }, exec);
|
||
tool.onResult(exec, { isError: false });
|
||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||
return artifact;
|
||
}
|
||
|
||
test('Feishu remembers any authorized private inbound message as a connection-test target', async () => {
|
||
const groupFixture = stateFixture();
|
||
const groupBridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness: { ensureRunning: async () => true },
|
||
state: groupFixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
await groupBridge.accept(event('target-group', '/help', {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_group',
|
||
}));
|
||
await groupBridge.waitForIdle();
|
||
assert.equal(connectionTestTarget(groupFixture.state), null);
|
||
|
||
const rejectedFixture = stateFixture();
|
||
const rejectedBridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness: { ensureRunning: async () => true },
|
||
state: rejectedFixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await rejectedBridge.accept(event('target-rejected', '/help', {
|
||
senderOpenId: 'ou_other',
|
||
}));
|
||
await rejectedBridge.waitForIdle();
|
||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||
|
||
const privateFixture = stateFixture();
|
||
const privateBridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness: { ensureRunning: async () => true },
|
||
state: privateFixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['*']),
|
||
});
|
||
await privateBridge.accept(event('target-private', '/help', {
|
||
chat_id: 'oc_private',
|
||
}));
|
||
await privateBridge.waitForIdle();
|
||
assert.deepEqual(connectionTestTarget(privateFixture.state), { chatId: 'oc_private' });
|
||
});
|
||
|
||
test('Feishu executes /compact for the bound Session without prompting the model', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-compact']]);
|
||
const sent = [];
|
||
const executed = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
executeCommand: async (sessionId, line) => {
|
||
executed.push({ sessionId, line });
|
||
return { commandId: 'compact-feishu', result: { kind: 'success', text: 'No compactable history yet.' } };
|
||
},
|
||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('compact-feishu', '/compact'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(executed, [{ sessionId: 'session-compact', line: '/compact' }]);
|
||
assert.deepEqual(sent, ['暂无可压缩的历史记录。']);
|
||
});
|
||
|
||
test('Feishu lists models and presets without prompting and advertises fast commands', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const presetUpdates = [];
|
||
let agentPreset = null;
|
||
let asks = 0;
|
||
let creates = 0;
|
||
const agentPresetCatalog = {
|
||
defaultId: 'preset-001',
|
||
items: Array.from({ length: 70 }, (_, index) => ({
|
||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||
label: `Feishu Preset ${index + 1} ${'x'.repeat(64)}`,
|
||
})),
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
listModels: async () => ({
|
||
groups: [{
|
||
id: 'feishu-provider',
|
||
name: 'Feishu Provider',
|
||
models: [{ id: 'model-one', name: 'Model One' }],
|
||
}],
|
||
failures: [],
|
||
}),
|
||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||
updateAgentPreset: async (value) => {
|
||
presetUpdates.push(value);
|
||
agentPreset = value;
|
||
return { agentPreset, agentPresetCatalog };
|
||
},
|
||
createSession: async () => { creates += 1; return 'feishu-session'; },
|
||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('models-feishu', '/models'));
|
||
await bridge.waitForIdle();
|
||
assert.match(sent.at(-1), /1\. feishu-provider\/model-one/);
|
||
assert.equal(asks, 0);
|
||
assert.equal(creates, 0);
|
||
assert.equal(fixture.sessions.size, 0);
|
||
|
||
await bridge.accept(event('reasoning-feishu', '/reasoninglist'));
|
||
await bridge.waitForIdle();
|
||
assert.match(sent.at(-1), /还没有会话/);
|
||
assert.equal(asks, 0);
|
||
assert.equal(creates, 0);
|
||
assert.equal(fixture.sessions.size, 0);
|
||
|
||
const presetReplyStart = sent.length;
|
||
await bridge.accept(event('presets-feishu', '/presetlist'));
|
||
await bridge.waitForIdle();
|
||
const presetReplies = sent.slice(presetReplyStart);
|
||
assert.ok(presetReplies.length > 1);
|
||
assert.match(presetReplies.join('\n'), /preset-070/);
|
||
assert.equal(asks, 0);
|
||
assert.equal(creates, 0);
|
||
assert.equal(fixture.sessions.size, 0);
|
||
|
||
await bridge.accept(event('preset-current-feishu', '/preset'));
|
||
await bridge.waitForIdle();
|
||
assert.match(sent.at(-1), /跟随 Host 默认/);
|
||
assert.equal(asks, 0);
|
||
assert.equal(creates, 0);
|
||
|
||
const selectReplyStart = sent.length;
|
||
await bridge.accept(event('preset-select-feishu', '/preset 2'));
|
||
await bridge.waitForIdle();
|
||
assert.deepEqual(presetUpdates, ['preset-002']);
|
||
assert.equal(sent.length, selectReplyStart + 1);
|
||
assert.match(sent.at(-1), /preset-002/);
|
||
|
||
const defaultReplyStart = sent.length;
|
||
await bridge.accept(event('preset-default-feishu', '/preset --default'));
|
||
await bridge.waitForIdle();
|
||
assert.deepEqual(presetUpdates, ['preset-002', null]);
|
||
assert.equal(sent.length, defaultReplyStart + 1);
|
||
assert.match(sent.at(-1), /跟随 Host 默认/);
|
||
assert.equal(asks, 0);
|
||
assert.equal(creates, 0);
|
||
assert.equal(fixture.sessions.size, 0);
|
||
|
||
await bridge.accept(event('help-feishu', '/help'));
|
||
await bridge.waitForIdle();
|
||
const help = sent.at(-1);
|
||
for (const command of [
|
||
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
|
||
'/presetlist', '/preset', '/preset --default', '/batch', '/send', '/cancel',
|
||
'/stop', '/steer', '/version',
|
||
]) {
|
||
assert.equal(help.includes(command), true, command);
|
||
}
|
||
assert.match(help, /\/model .*\[推理等级ID\]/);
|
||
assert.match(help, /\/preset id:<ID>/);
|
||
});
|
||
|
||
test('Feishu private batch input synchronously collects messages and submits one ordered prompt', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-batch']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const reactions = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {
|
||
addReaction: async (messageId, emojiType) => {
|
||
reactions.push({ messageId, emojiType });
|
||
return `${messageId}-${emojiType}`;
|
||
},
|
||
removeReaction: async () => undefined,
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asked.push(text);
|
||
return '批量处理完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const operations = [
|
||
bridge.accept(event('batch-open', '/batch')),
|
||
bridge.accept(event('batch-one', '第一条')),
|
||
bridge.accept(event('batch-two', '第二条')),
|
||
bridge.accept(event('batch-progress', '/batch')),
|
||
bridge.accept(event('batch-send', '/send')),
|
||
];
|
||
await Promise.all(operations);
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(asked, [
|
||
'以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。\n\n'
|
||
+ '[消息 1]\n第一条\n\n[消息 2]\n第二条',
|
||
]);
|
||
assert.match(sent.join('\n'), /已进入批量输入模式/);
|
||
assert.match(sent.join('\n'), /已收集 2\/10 条/);
|
||
assert.match(sent.join('\n'), /批量处理完成/);
|
||
for (const messageId of ['batch-open', 'batch-one', 'batch-two', 'batch-progress', 'batch-send']) {
|
||
assert.equal(fixture.seen.has(messageId), true, messageId);
|
||
assert.deepEqual(
|
||
reactions.filter((reaction) => reaction.messageId === messageId).map(({ emojiType }) => emojiType),
|
||
['OnIt', 'DONE'],
|
||
);
|
||
}
|
||
|
||
await bridge.accept(event('batch-after-success', '恢复普通聊天'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(asked.at(-1), '恢复普通聊天');
|
||
});
|
||
|
||
test('Feishu batch input caps at ten, rejects non-text content, and can be cancelled', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-limit']]);
|
||
const sent = [];
|
||
let asks = 0;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
asks += 1;
|
||
return '不应调用';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('batch-limit-open', '/batch'));
|
||
for (let index = 1; index <= 10; index += 1) {
|
||
await bridge.accept(event(`batch-limit-${index}`, `内容 ${index}`));
|
||
}
|
||
await bridge.accept(event('batch-limit-overflow', '第十一条'));
|
||
await bridge.accept(event('batch-limit-cancel', '/cancel'));
|
||
assert.equal(asks, 0);
|
||
assert.match(sent.join('\n'), /已收集 10\/10 条/);
|
||
assert.match(sent.join('\n'), /当前批次已满,这条消息未收录/);
|
||
assert.match(sent.join('\n'), /共丢弃 10 条消息/);
|
||
|
||
await bridge.accept(event('batch-nontext-open', '/batch'));
|
||
await bridge.accept(event('batch-nontext-image', '', {
|
||
message_type: 'image',
|
||
content: JSON.stringify({ image_key: 'img_batch_rejected' }),
|
||
}));
|
||
await bridge.accept(event('batch-nontext-cancel', '/cancel'));
|
||
assert.equal(asks, 0);
|
||
assert.match(sent.join('\n'), /目前仅支持文字,这条消息未收录/);
|
||
assert.match(sent.at(-1), /已取消批量输入/);
|
||
assert.doesNotMatch(sent.at(-1), /丢弃 1 条/);
|
||
});
|
||
|
||
test('Feishu group batch commands follow the response policy but never reach Harness', async () => {
|
||
const fixture = stateFixture([['group:oc_batch_group', 'session-group-batch']]);
|
||
const sent = [];
|
||
let asks = 0;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
asks += 1;
|
||
return '不应调用';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
botOpenId: 'ou_bot',
|
||
groupResponseMode: 'mention',
|
||
});
|
||
|
||
for (const [messageId, command] of [
|
||
['group-batch', '/batch'],
|
||
['group-send', '/send'],
|
||
['group-cancel', '/cancel'],
|
||
]) {
|
||
await bridge.accept(event(messageId, `@_bot ${command}`, {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_batch_group',
|
||
mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }],
|
||
}));
|
||
}
|
||
await bridge.accept(event('group-batch-unaddressed', '/batch', {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_batch_group',
|
||
}));
|
||
bridge.setGroupResponseMode('all');
|
||
await bridge.accept(event('group-batch-all-mode', '/batch', {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_batch_group',
|
||
}));
|
||
|
||
assert.equal(asks, 0);
|
||
assert.equal(sent.length, 4);
|
||
assert.equal(sent.every((text) => /仅支持私聊/.test(text)), true);
|
||
assert.equal(fixture.seen.has('group-batch-unaddressed'), false);
|
||
assert.equal(fixture.seen.has('group-batch-all-mode'), true);
|
||
});
|
||
|
||
test('Feishu batch start is busy behind a turn and submitting lets later messages use the normal queue', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-queue']]);
|
||
const firstStarted = deferred();
|
||
const releaseFirst = deferred();
|
||
const batchStarted = deferred();
|
||
const releaseBatch = deferred();
|
||
const asked = [];
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asked.push(text);
|
||
if (text === '运行中的普通任务') {
|
||
firstStarted.resolve();
|
||
await releaseFirst.promise;
|
||
} else if (text.includes('[消息 1]\n批量内容')) {
|
||
batchStarted.resolve();
|
||
await releaseBatch.promise;
|
||
}
|
||
return `完成:${text}`;
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const activeTurn = bridge.accept(event('batch-busy-turn', '运行中的普通任务'));
|
||
await firstStarted.promise;
|
||
await bridge.accept(event('batch-busy-open', '/batch'));
|
||
assert.match(sent.at(-1), /正在运行的任务/);
|
||
releaseFirst.resolve();
|
||
await activeTurn;
|
||
|
||
await bridge.accept(event('batch-queue-open', '/batch'));
|
||
await bridge.accept(event('batch-queue-content', '批量内容'));
|
||
const submission = bridge.accept(event('batch-queue-send', '/send'));
|
||
await batchStarted.promise;
|
||
const laterMessage = bridge.accept(event('batch-queue-later', '提交后的普通消息'));
|
||
await bridge.accept(event('batch-queue-cancel-late', '/cancel'));
|
||
assert.match(sent.at(-1), /已经提交,无法取消/);
|
||
releaseBatch.resolve();
|
||
await Promise.all([submission, laterMessage]);
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(asked.length, 3);
|
||
assert.equal(asked[0], '运行中的普通任务');
|
||
assert.match(asked[1], /\[消息 1\]\n批量内容/);
|
||
assert.equal(asked[2], '提交后的普通消息');
|
||
});
|
||
|
||
test('Feishu retains a batch after an ask failure and retries the same snapshot', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-retry']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const finalReactions = [];
|
||
const status = bridgeStatus();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {
|
||
addReaction: async (_messageId, emojiType) => {
|
||
finalReactions.push(emojiType);
|
||
return `reaction-${emojiType}`;
|
||
},
|
||
removeReaction: async () => undefined,
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asked.push(text);
|
||
if (asked.length === 1) {
|
||
const error = new Error('private transient batch rate-limit detail');
|
||
error.code = 'harness-turn-failed';
|
||
error.providerCode = 'RATE_LIMIT';
|
||
throw error;
|
||
}
|
||
return '重试成功';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('batch-retry-open', '/batch'));
|
||
await bridge.accept(event('batch-retry-content', '需要重试'));
|
||
await bridge.accept(event('batch-retry-first-send', '/send'));
|
||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||
assert.match(sent.at(-1), /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
|
||
assert.match(sent.at(-1), /提交失败,已保留 1 条消息/);
|
||
assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT');
|
||
assert.equal(sent.at(-1).includes(`参考号:${status.lastMessageError.referenceId}`), true);
|
||
assert.doesNotMatch(sent.at(-1), /private transient batch rate-limit detail/);
|
||
assert.equal(finalReactions.at(-1), 'ERROR');
|
||
|
||
await bridge.accept(event('batch-retry-status', '/batch'));
|
||
assert.match(sent.at(-1), /已收集 1\/10 条/);
|
||
await bridge.accept(event('batch-retry-second-send', '/send'));
|
||
assert.equal(asked.length, 2);
|
||
assert.equal(asked[0], asked[1]);
|
||
assert.equal(sent.at(-1), '重试成功');
|
||
|
||
await bridge.accept(event('batch-retry-cancel-after-success', '/cancel'));
|
||
assert.match(sent.at(-1), /没有正在进行的批量输入/);
|
||
});
|
||
|
||
test('Feishu clears a submitted batch when the Harness turn is stopped', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-batch-stopped']]);
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
const error = new Error('turn stopped');
|
||
error.code = 'turn-stopped';
|
||
throw error;
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('stopped-batch-open', '/batch'));
|
||
await bridge.accept(event('stopped-batch-content', '不要重试'));
|
||
await bridge.accept(event('stopped-batch-send', '/send'));
|
||
assert.doesNotMatch(sent.join('\n'), /已保留/);
|
||
|
||
await bridge.accept(event('stopped-batch-cancel', '/cancel'));
|
||
assert.match(sent.at(-1), /没有正在进行的批量输入/);
|
||
});
|
||
|
||
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
|
||
const sent = [];
|
||
const reactions = [];
|
||
const removedReactions = [];
|
||
const streamed = [];
|
||
const sessions = new Map();
|
||
const seen = new Set();
|
||
const asked = [];
|
||
const client = {
|
||
im: { v1: { message: { create: async (request) => {
|
||
sent.push(request);
|
||
return { code: 0 };
|
||
} } } },
|
||
};
|
||
const channel = {
|
||
addReaction: async (messageId, emojiType) => {
|
||
reactions.push({ messageId, emojiType });
|
||
return `reaction-${emojiType}`;
|
||
},
|
||
removeReaction: async (messageId, reactionId) => {
|
||
removedReactions.push({ messageId, reactionId });
|
||
},
|
||
stream: async (chatId, input, options) => {
|
||
const updates = [];
|
||
await input.markdown({
|
||
setContent: async (content) => updates.push(content),
|
||
});
|
||
streamed.push({ chatId, options, updates });
|
||
return { messageId: 'om_reply' };
|
||
},
|
||
};
|
||
const harness = {
|
||
ensureRunning: async () => true,
|
||
sessionExists: async (sessionId) => sessionId === 'session-test',
|
||
createSession: async () => 'session-test',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
await options.onUpdate({ type: 'text', text: 'Harness' });
|
||
return 'Harness reply';
|
||
},
|
||
};
|
||
const state = {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: (key) => sessions.get(key) ?? null,
|
||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||
clearSession: async (key) => sessions.delete(key),
|
||
};
|
||
const status = {
|
||
messagesReceived: 0,
|
||
messagesReplied: 0,
|
||
messagesRejected: 0,
|
||
lastMessageAt: null,
|
||
lastReplyAt: null,
|
||
lastRejectedAt: null,
|
||
lastError: null,
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel,
|
||
harness,
|
||
state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_1', '你好'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(sessions.get('p2p:ou_user'), 'session-test');
|
||
assert.deepEqual(asked, [{ sessionId: 'session-test', text: '你好' }]);
|
||
assert.deepEqual(streamed, [{
|
||
chatId: 'oc_chat',
|
||
options: { replyTo: 'om_1' },
|
||
updates: ['Harness', 'Harness reply'],
|
||
}]);
|
||
assert.deepEqual(reactions, [
|
||
{ messageId: 'om_1', emojiType: 'OnIt' },
|
||
{ messageId: 'om_1', emojiType: 'DONE' },
|
||
]);
|
||
assert.deepEqual(removedReactions, [
|
||
{ messageId: 'om_1', reactionId: 'reaction-OnIt' },
|
||
]);
|
||
assert.equal(sent.length, 0);
|
||
assert.equal(status.messagesReceived, 1);
|
||
assert.equal(status.messagesReplied, 1);
|
||
assert.equal(status.streamResponses, 1);
|
||
|
||
bridge.accept(event('om_1', '重复消息'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(asked.length, 1);
|
||
|
||
bridge.accept({
|
||
...event('om_2', '越权消息'),
|
||
sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } },
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(asked.length, 1);
|
||
assert.equal(status.messagesRejected, 1);
|
||
});
|
||
|
||
test('mention response mode ignores unaddressed groups and only accepts this bot mention', async () => {
|
||
const fixture = stateFixture([['group:oc_group_mentions', 'session-group-mentions']]);
|
||
const asked = [];
|
||
const status = bridgeStatus();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => undefined),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asked.push(text);
|
||
return '收到';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
botOpenId: 'ou_bot',
|
||
groupResponseMode: 'mention',
|
||
});
|
||
|
||
await bridge.accept(event('group-unaddressed', '普通群消息', {
|
||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||
}));
|
||
await bridge.accept(event('group-mentions-someone-else', '@_other 你好', {
|
||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||
mentions: [{ key: '@_other', id: { open_id: 'ou_other' } }],
|
||
}));
|
||
assert.deepEqual(asked, []);
|
||
assert.equal(status.messagesReceived, 0);
|
||
|
||
await bridge.accept(event('group-mentions-bot', '@_bot 你好', {
|
||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||
mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }],
|
||
}));
|
||
assert.deepEqual(asked, ['你好']);
|
||
assert.equal(status.messagesReceived, 1);
|
||
|
||
bridge.setGroupResponseMode('all');
|
||
await bridge.accept(event('group-all-mode', '无需提及', {
|
||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||
}));
|
||
assert.deepEqual(asked, ['你好', '无需提及']);
|
||
});
|
||
|
||
test('bridge downloads an inbound Feishu image once and submits structured Harness content', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-image']]);
|
||
const downloaded = [];
|
||
const asked = [];
|
||
const sent = [];
|
||
const client = {
|
||
im: { v1: {
|
||
messageResource: { get: async (request) => {
|
||
downloaded.push(request);
|
||
return {
|
||
headers: { 'content-length': String(PNG_1X1.length) },
|
||
getReadableStream: () => Readable.from([PNG_1X1]),
|
||
};
|
||
} },
|
||
message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0, data: { message_id: 'om_image_reply' } };
|
||
} },
|
||
} },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, content) => {
|
||
asked.push({ sessionId, content });
|
||
return '看到了一张图片';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
const imageEvent = event('om_image_input', '', {
|
||
message_type: 'image',
|
||
content: JSON.stringify({ image_key: 'img_input' }),
|
||
});
|
||
|
||
await bridge.accept(imageEvent);
|
||
await bridge.accept(imageEvent);
|
||
await bridge.accept({
|
||
...event('om_image_unauthorized', '', {
|
||
message_type: 'image',
|
||
content: JSON.stringify({ image_key: 'img_unauthorized' }),
|
||
}),
|
||
sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } },
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(downloaded, [{
|
||
path: { message_id: 'om_image_input', file_key: 'img_input' },
|
||
params: { type: 'image' },
|
||
}]);
|
||
assert.deepEqual(asked, [{
|
||
sessionId: 'session-image',
|
||
content: [
|
||
{ type: 'text', text: DEFAULT_IMAGE_PROMPT },
|
||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||
],
|
||
}]);
|
||
assert.deepEqual(sent, ['看到了一张图片']);
|
||
});
|
||
|
||
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
|
||
const bytes = Buffer.from('feishu-native-file');
|
||
const downloads = [];
|
||
const asked = [];
|
||
const sent = [];
|
||
const client = {
|
||
im: { v1: {
|
||
messageResource: { get: async (request) => {
|
||
downloads.push(request);
|
||
return { getReadableStream: () => Readable.from([bytes]) };
|
||
} },
|
||
message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0, data: { message_id: 'om_file_reply' } };
|
||
} },
|
||
} },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, prompt, options) => {
|
||
asked.push({
|
||
sessionId,
|
||
prompt,
|
||
name: options.files[0].name,
|
||
bytes: await options.files[0].load({ signal: options.signal }),
|
||
});
|
||
return '文件已收到';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('om_file_input', '', {
|
||
message_type: 'file',
|
||
content: JSON.stringify({ file_key: 'file_input', file_name: '飞书报告.pdf' }),
|
||
}));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(downloads, [{
|
||
path: { message_id: 'om_file_input', file_key: 'file_input' },
|
||
params: { type: 'file' },
|
||
}]);
|
||
assert.deepEqual(asked, [{
|
||
sessionId: 'session-file',
|
||
prompt: '',
|
||
name: '飞书报告.pdf',
|
||
bytes,
|
||
}]);
|
||
assert.deepEqual(sent, ['文件已收到']);
|
||
});
|
||
|
||
test('bridge tells users to grant im:message:readonly when Feishu rejects image access', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-image-permission']]);
|
||
const sent = [];
|
||
const providerError = new Error('Request failed with status code 400');
|
||
providerError.code = 'ERR_BAD_REQUEST';
|
||
providerError.response = {
|
||
status: 400,
|
||
data: Readable.from([Buffer.from(JSON.stringify({
|
||
code: 99991672,
|
||
msg: 'secret-shaped provider detail /private/path',
|
||
}))]),
|
||
};
|
||
const client = {
|
||
im: { v1: {
|
||
messageResource: { get: async () => { throw providerError; } },
|
||
message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0, data: { message_id: 'om_permission_reply' } };
|
||
} },
|
||
} },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => assert.fail('permission failures must not reach Harness'),
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('om_image_permission', '', {
|
||
message_type: 'image',
|
||
content: JSON.stringify({ image_key: 'img_permission' }),
|
||
}));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(sent.length, 1);
|
||
assert.match(sent[0], /im:message:readonly/);
|
||
assert.match(sent[0], /\/repair/);
|
||
assert.match(sent[0], /发布新版本/);
|
||
assert.match(sent[0], /「IM机器人」设置页/);
|
||
assert.match(sent[0], /补全权限/);
|
||
assert.doesNotMatch(sent[0], /99991672|HTTP 400|secret-shaped|private\/path/);
|
||
});
|
||
|
||
test('bridge sends Feishu post text and all embedded images as one structured prompt', async () => {
|
||
const fixture = stateFixture([['group:oc_post_group', 'session-post']]);
|
||
const downloaded = [];
|
||
const asked = [];
|
||
const sent = [];
|
||
const client = {
|
||
im: { v1: {
|
||
messageResource: { get: async (request) => {
|
||
downloaded.push(request);
|
||
return {
|
||
headers: { 'content-length': String(PNG_1X1.length) },
|
||
getReadableStream: () => Readable.from([PNG_1X1]),
|
||
};
|
||
} },
|
||
message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0, data: { message_id: 'om_post_reply' } };
|
||
} },
|
||
} },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, content) => {
|
||
asked.push({ sessionId, content });
|
||
return '两张图片都已收到';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
const postEvent = event('om_post_input', '', {
|
||
message_type: 'post',
|
||
chat_type: 'group',
|
||
chat_id: 'oc_post_group',
|
||
mentions: [{ key: '@_bot_1' }],
|
||
content: JSON.stringify({
|
||
title: '比较截图',
|
||
content: [
|
||
[
|
||
{ tag: 'at', user_id: '@_bot_1', user_name: '机器人' },
|
||
{ tag: 'text', text: '@_bot_1 请比较 ' },
|
||
{ tag: 'a', text: '这两张图', href: 'https://example.com' },
|
||
],
|
||
[{ tag: 'img', image_key: 'img_post_first' }],
|
||
[{ tag: 'img', image_key: 'img_post_second' }],
|
||
],
|
||
}),
|
||
});
|
||
|
||
await bridge.accept(postEvent);
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(downloaded, [
|
||
{
|
||
path: { message_id: 'om_post_input', file_key: 'img_post_first' },
|
||
params: { type: 'image' },
|
||
},
|
||
{
|
||
path: { message_id: 'om_post_input', file_key: 'img_post_second' },
|
||
params: { type: 'image' },
|
||
},
|
||
]);
|
||
assert.deepEqual(asked, [{
|
||
sessionId: 'session-post',
|
||
content: [
|
||
{ type: 'text', text: '比较截图\n请比较 这两张图' },
|
||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||
],
|
||
}]);
|
||
assert.deepEqual(sent, ['两张图片都已收到']);
|
||
});
|
||
|
||
test('a single-text-paragraph Feishu post is treated as a command like a text message', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-post-command']]);
|
||
const asked = [];
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, content) => {
|
||
asked.push({ sessionId, content });
|
||
return '按普通内容处理';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('om_post_command', '', {
|
||
message_type: 'post',
|
||
content: JSON.stringify({ content: [[{ tag: 'text', text: '/new' }]] }),
|
||
}));
|
||
await bridge.waitForIdle();
|
||
|
||
// /new 在 post 单文本段落中现在按命令处理:解除会话绑定并回复确认。
|
||
assert.equal(fixture.sessions.get('p2p:ou_user'), undefined);
|
||
assert.ok(sent.some((line) => /已开启全新/.test(line)));
|
||
assert.deepEqual(asked, []);
|
||
});
|
||
|
||
test('a threaded Feishu reply answers a pending Harness question before the original turn queue', async () => {
|
||
const sent = [];
|
||
const streamed = [];
|
||
const asked = [];
|
||
const seen = new Set();
|
||
const sessions = new Map([['p2p:ou_user', 'session-question']]);
|
||
const submitStarted = deferred();
|
||
const releaseSubmit = deferred();
|
||
const answerAccepted = deferred();
|
||
let originalTurnFinished = false;
|
||
const status = {
|
||
messagesReceived: 0,
|
||
messagesReplied: 0,
|
||
messagesRejected: 0,
|
||
lastMessageAt: null,
|
||
lastReplyAt: null,
|
||
lastRejectedAt: null,
|
||
lastError: null,
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {
|
||
im: { v1: { message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0, data: { message_id: `om_sent_${sent.length}` } };
|
||
} } } },
|
||
},
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({
|
||
setContent: async (content) => streamed.push(content),
|
||
});
|
||
originalTurnFinished = true;
|
||
return { messageId: 'om_stream' };
|
||
},
|
||
},
|
||
harness: {
|
||
ensureRunning: async () => true,
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
await options.onUpdate({ type: 'tool', name: 'ask_user_question' });
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'question-rpc',
|
||
rpcId: 'question-rpc',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{
|
||
id: 'environment',
|
||
header: '测试环境',
|
||
question: '请选择测试环境',
|
||
options: [{ label: '测试环境' }, { label: '生产环境' }],
|
||
}],
|
||
},
|
||
respond: async (result) => {
|
||
submitStarted.resolve(result);
|
||
await releaseSubmit.promise;
|
||
answerAccepted.resolve();
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await answerAccepted.promise;
|
||
return '你选择了:测试环境';
|
||
},
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: (key) => sessions.get(key) ?? null,
|
||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||
clearSession: async (key) => sessions.delete(key),
|
||
},
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_prompt', '请先调用 ask_user_question'));
|
||
await eventually(
|
||
() => [...sent, ...streamed].some((text) => text.includes('请选择测试环境')),
|
||
'the Harness question was not presented in Feishu',
|
||
);
|
||
|
||
bridge.accept(event('om_answer', '1', {
|
||
root_id: 'om_prompt',
|
||
parent_id: 'om_sent_1',
|
||
thread_id: 'omt_question_thread',
|
||
}));
|
||
const submitted = await Promise.race([
|
||
submitStarted.promise,
|
||
new Promise((_, reject) => setTimeout(
|
||
() => reject(new Error('threaded Feishu answer deadlocked behind the original turn')),
|
||
500,
|
||
)),
|
||
]);
|
||
|
||
assert.equal(originalTurnFinished, false);
|
||
assert.deepEqual(submitted, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-question',
|
||
answer: {
|
||
answers: [{ id: 'environment', selected: ['测试环境'] }],
|
||
},
|
||
},
|
||
});
|
||
assert.deepEqual(asked, [{
|
||
sessionId: 'session-question',
|
||
text: '请先调用 ask_user_question',
|
||
}]);
|
||
|
||
// This matches the screenshot: /status can arrive while the answer is being
|
||
// submitted. It may wait for the original turn, but it must not remain stuck.
|
||
bridge.accept(event('om_status', '/status'));
|
||
releaseSubmit.resolve();
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(originalTurnFinished, true);
|
||
assert.equal(streamed.at(-1), '你选择了:测试环境');
|
||
assert.equal(sent.some((text) => text.includes('连接正常')), true);
|
||
assert.deepEqual(asked, [{
|
||
sessionId: 'session-question',
|
||
text: '请先调用 ask_user_question',
|
||
}], 'the answer and /status must not become new Harness prompts');
|
||
assert.deepEqual([...seen].sort(), ['om_answer', 'om_prompt', 'om_status']);
|
||
assert.equal(status.messagesReceived, 3);
|
||
assert.equal(status.messagesReplied, 1);
|
||
});
|
||
|
||
test('a Harness question is presented as a threaded reply inside a topic group', async () => {
|
||
const sent = [];
|
||
const replied = [];
|
||
const streamed = [];
|
||
const seen = new Set();
|
||
const sessions = new Map();
|
||
const status = {
|
||
messagesReceived: 0,
|
||
messagesReplied: 0,
|
||
messagesRejected: 0,
|
||
lastMessageAt: null,
|
||
lastReplyAt: null,
|
||
lastRejectedAt: null,
|
||
lastError: null,
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {
|
||
im: { v1: { message: {
|
||
create: async (request) => {
|
||
sent.push({ text: JSON.parse(request.data.content).text });
|
||
return { code: 0, data: { message_id: `om_sent_${sent.length}` } };
|
||
},
|
||
reply: async (request) => {
|
||
replied.push({
|
||
to: request.path.message_id,
|
||
text: JSON.parse(request.data.content).text,
|
||
});
|
||
return { code: 0, data: { message_id: `om_replied_${replied.length}` } };
|
||
},
|
||
} } },
|
||
},
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({
|
||
setContent: async (content) => streamed.push(content),
|
||
});
|
||
return { messageId: 'om_stream' };
|
||
},
|
||
},
|
||
harness: {
|
||
ensureRunning: async () => true,
|
||
sessionExists: async () => false,
|
||
createSession: async () => 'session-topic',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onUpdate({ type: 'tool', name: 'ask_user_question' });
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'question-rpc',
|
||
rpcId: 'question-rpc',
|
||
sessionId: 'session-topic',
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId: 'session-topic',
|
||
questions: [{
|
||
id: 'environment',
|
||
header: '测试环境',
|
||
question: '请选择测试环境',
|
||
options: [{ label: '测试环境' }, { label: '生产环境' }],
|
||
}],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
});
|
||
return '已完成';
|
||
},
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: (key) => sessions.get(key) ?? null,
|
||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||
clearSession: async (key) => sessions.delete(key),
|
||
},
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_prompt', '请先调用 ask_user_question', {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_topic_group',
|
||
thread_id: 'omt_prompt',
|
||
}));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(
|
||
replied,
|
||
[{ to: 'om_prompt', text: replied[0]?.text }],
|
||
'the question must be delivered through the reply API targeting the triggering message',
|
||
);
|
||
assert.ok(replied[0]?.text.includes('请选择测试环境'));
|
||
assert.equal(
|
||
sent.some(({ text }) => text.includes('请选择测试环境')),
|
||
false,
|
||
'the question must not be sent as a plain chat message that lands outside the topic',
|
||
);
|
||
assert.equal(streamed.at(-1), '已完成');
|
||
});
|
||
|
||
test('command replies in a topic group are threaded to the triggering message', async () => {
|
||
const created = [];
|
||
const replied = [];
|
||
const seen = new Set();
|
||
const sessions = new Map([['group:oc_topic:thread:omt_cmd', 'session-cmd']]);
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {
|
||
im: { v1: { message: {
|
||
create: async (request) => {
|
||
created.push({
|
||
type: request.data.msg_type,
|
||
text: request.data.msg_type === 'text'
|
||
? JSON.parse(request.data.content).text
|
||
: null,
|
||
});
|
||
return { code: 0, data: { message_id: `om_created_${created.length}` } };
|
||
},
|
||
reply: async (request) => {
|
||
replied.push({
|
||
to: request.path.message_id,
|
||
type: request.data.msg_type,
|
||
text: request.data.msg_type === 'text'
|
||
? JSON.parse(request.data.content).text
|
||
: null,
|
||
});
|
||
return { code: 0, data: { message_id: `om_replied_${replied.length}` } };
|
||
},
|
||
} } },
|
||
},
|
||
harness: {
|
||
ensureRunning: async () => true,
|
||
sessionExists: async () => true,
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: (key) => sessions.get(key) ?? null,
|
||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||
clearSession: async (key) => sessions.delete(key),
|
||
},
|
||
status: {
|
||
messagesReceived: 0,
|
||
messagesReplied: 0,
|
||
messagesRejected: 0,
|
||
lastMessageAt: null,
|
||
lastReplyAt: null,
|
||
lastRejectedAt: null,
|
||
lastError: null,
|
||
},
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_cmd', '/new', {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_topic',
|
||
thread_id: 'omt_cmd',
|
||
}));
|
||
await eventually(
|
||
() => replied.length >= 2,
|
||
'the /new replies were not presented as threaded replies',
|
||
);
|
||
|
||
assert.ok(
|
||
replied.some(({ to, text }) => to === 'om_cmd' && text?.includes('已开启全新')),
|
||
'the /new confirmation must be delivered through the reply API targeting the command message',
|
||
);
|
||
assert.ok(
|
||
replied.some(({ to, type }) => to === 'om_cmd' && type === 'interactive'),
|
||
'the menu card must also be threaded to the command message',
|
||
);
|
||
assert.equal(
|
||
created.some(({ text }) => text?.includes('已开启全新')),
|
||
false,
|
||
'the /new confirmation must not be sent as a plain chat message outside the topic',
|
||
);
|
||
});
|
||
|
||
test('pending Harness questions are isolated by Feishu conversation', async () => {
|
||
const fixture = stateFixture([
|
||
['p2p:ou_a', 'session-a'],
|
||
['p2p:ou_b', 'session-b'],
|
||
]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const answeredA = deferred();
|
||
const releaseA = deferred();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('existing sessions should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
if (sessionId === 'session-b') return '乙会话的普通回答';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'question-a',
|
||
rpcId: 'question-a',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'a', question: '甲会话的问题' }],
|
||
},
|
||
respond: async (result) => {
|
||
answeredA.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await answeredA.promise;
|
||
await releaseA.promise;
|
||
return '甲会话完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_a', 'ou_b']),
|
||
});
|
||
|
||
const firstA = bridge.accept(event('a-prompt', '启动甲会话', {
|
||
senderOpenId: 'ou_a',
|
||
chat_id: 'oc_a',
|
||
}));
|
||
await eventually(() => sent.some(({ text }) => text.includes('甲会话的问题')));
|
||
|
||
await bridge.accept(event('b-message', '乙会话的消息', {
|
||
senderOpenId: 'ou_b',
|
||
chat_id: 'oc_b',
|
||
}));
|
||
assert.deepEqual(asked, [
|
||
{ sessionId: 'session-a', text: '启动甲会话' },
|
||
{ sessionId: 'session-b', text: '乙会话的消息' },
|
||
]);
|
||
assert.equal(sent.some(({ chatId, text }) => (
|
||
chatId === 'oc_b' && text === '乙会话的普通回答'
|
||
)), true);
|
||
|
||
await bridge.accept(event('a-answer', '甲的答案', {
|
||
senderOpenId: 'ou_a',
|
||
chat_id: 'oc_a',
|
||
}));
|
||
assert.deepEqual((await answeredA.promise).value.answer.answers, [
|
||
{ id: 'a', selected: [], custom: '甲的答案' },
|
||
]);
|
||
releaseA.resolve();
|
||
await firstA;
|
||
});
|
||
|
||
test('Feishu handles approval replies on the fast lane and presents approvals in FIFO order', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-approval']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const decisions = [];
|
||
const decided = deferred();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
const approval = (approvalId, toolName, reason) => ({
|
||
kind: 'approval',
|
||
interactionId: approvalId,
|
||
rpcId: `rpc-${approvalId}`,
|
||
sessionId,
|
||
payload: {
|
||
type: 'approval/requested',
|
||
sessionId,
|
||
approvalId,
|
||
toolName,
|
||
callId: `call-${approvalId}`,
|
||
reason,
|
||
},
|
||
toolCall: {
|
||
callId: `call-${approvalId}`,
|
||
name: toolName,
|
||
arguments: JSON.stringify({ operation: reason }),
|
||
},
|
||
respond: async (result) => {
|
||
decisions.push(result);
|
||
if (decisions.length === 2) decided.resolve();
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await options.onInteraction(approval(
|
||
'approval-build',
|
||
'bash',
|
||
'运行第一项构建操作',
|
||
));
|
||
await options.onInteraction(approval(
|
||
'approval-write',
|
||
'write_file',
|
||
'运行第二项写入操作',
|
||
));
|
||
await decided.promise;
|
||
return '两个审批均已处理';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const turn = bridge.accept(event('approval-start', '发起两个审批'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('运行第一项构建操作')));
|
||
assert.equal(sent.some(({ text }) => text.includes('运行第二项写入操作')), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('approval-build')), false);
|
||
|
||
await bridge.accept(event('approval-invalid', '好的'));
|
||
assert.deepEqual(decisions, []);
|
||
assert.deepEqual(asked, [{ sessionId: 'session-approval', text: '发起两个审批' }]);
|
||
assert.match(sent.at(-1).text, /批准/);
|
||
assert.match(sent.at(-1).text, /拒绝/);
|
||
|
||
await bridge.accept(event('approval-allow', '批准'));
|
||
assert.deepEqual(decisions, [{
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-approval',
|
||
approvalId: 'approval-build',
|
||
outcome: 'allowed-once',
|
||
},
|
||
}]);
|
||
assert.equal(sent.filter(({ text }) => text.includes('运行第二项写入操作')).length, 1);
|
||
assert.equal(sent.some(({ text }) => text.includes('approval-write')), false);
|
||
|
||
await bridge.accept(event('approval-reject', '拒绝'));
|
||
await turn;
|
||
|
||
assert.deepEqual(decisions, [
|
||
{
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-approval',
|
||
approvalId: 'approval-build',
|
||
outcome: 'allowed-once',
|
||
},
|
||
},
|
||
{
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-approval',
|
||
approvalId: 'approval-write',
|
||
outcome: 'rejected',
|
||
},
|
||
},
|
||
]);
|
||
assert.equal(sent.at(-1).text, '两个审批均已处理');
|
||
});
|
||
|
||
test('question replays are deduplicated and an unrenderable approval is safely rejected', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let approvalResponse;
|
||
let parallelQuestionResponse;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => false,
|
||
createSession: async () => 'session-replay',
|
||
ask: async (sessionId, _text, options) => {
|
||
const question = {
|
||
kind: 'question',
|
||
interactionId: 'replayed-question',
|
||
rpcId: 'replayed-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'choice', question: '只应显示一次' }],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
};
|
||
await options.onInteraction(question);
|
||
await options.onInteraction(question);
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'parallel-question',
|
||
rpcId: 'parallel-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'parallel', question: '不应无声丢弃' }],
|
||
},
|
||
respond: async (result) => {
|
||
parallelQuestionResponse = result;
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await options.onInteraction({
|
||
kind: 'approval',
|
||
interactionId: 'approval-one',
|
||
rpcId: 'approval-rpc',
|
||
sessionId,
|
||
payload: {
|
||
type: 'approval/requested',
|
||
sessionId,
|
||
approvalId: 'approval-one',
|
||
toolName: 'bash',
|
||
},
|
||
respond: async (result) => {
|
||
approvalResponse = result;
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
sessionId,
|
||
interactionId: 'replayed-question',
|
||
outcome: 'cancelled',
|
||
});
|
||
return '交互已取消';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('replay', '测试重放'));
|
||
|
||
assert.equal(sent.filter(({ text }) => text.includes('只应显示一次')).length, 1);
|
||
assert.deepEqual(parallelQuestionResponse, {
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'Feishu is already handling another user interaction.',
|
||
details: {},
|
||
},
|
||
});
|
||
assert.deepEqual(approvalResponse, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-replay',
|
||
approvalId: 'approval-one',
|
||
outcome: 'rejected',
|
||
},
|
||
});
|
||
assert.equal(sent.some(({ text }) => text.includes('无法完整展示')), true);
|
||
assert.equal(sent.at(-1).text, '交互已取消');
|
||
});
|
||
|
||
test('a queued next prompt stays separate while a failed interaction response is retried', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-submit-retry']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const firstSubmitStarted = deferred();
|
||
const releaseFirstSubmit = deferred();
|
||
const answered = deferred();
|
||
const submittedAnswers = [];
|
||
let submitAttempts = 0;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text === '排队的下一个问题') return '第二轮完成';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'submit-retry-question',
|
||
rpcId: 'submit-retry-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'answer', question: '请回答后再继续' }],
|
||
},
|
||
respond: async (result) => {
|
||
submittedAnswers.push(result.value.answer.answers[0].custom);
|
||
submitAttempts += 1;
|
||
if (submitAttempts === 1) {
|
||
firstSubmitStarted.resolve();
|
||
await releaseFirstSubmit.promise;
|
||
throw new Error('temporary response failure');
|
||
}
|
||
answered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await answered.promise;
|
||
return '第一轮完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
const first = bridge.accept(event('submit-retry-start', '启动可重试交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答后再继续')));
|
||
const firstAnswer = bridge.accept(event('submit-retry-answer', '第一次答案'));
|
||
await firstSubmitStarted.promise;
|
||
|
||
let nextSettled = false;
|
||
const next = bridge.accept(event('submit-retry-next', '排队的下一个问题'))
|
||
.finally(() => { nextSettled = true; });
|
||
releaseFirstSubmit.resolve();
|
||
await firstAnswer;
|
||
await eventually(() => sent.some(({ text }) => text.includes('回答提交失败')));
|
||
assert.equal(nextSettled, false);
|
||
assert.deepEqual(asked, ['启动可重试交互']);
|
||
|
||
const retry = bridge.accept(event('submit-retry-again', '重试后的答案'));
|
||
await Promise.all([retry, first, next]);
|
||
|
||
assert.deepEqual(submittedAnswers, ['第一次答案', '重试后的答案']);
|
||
assert.deepEqual(asked, ['启动可重试交互', '排队的下一个问题']);
|
||
assert.deepEqual(sent.slice(-2).map(({ text }) => text), ['第一轮完成', '第二轮完成']);
|
||
});
|
||
|
||
test('a rich-post pending reply does not block the valid text answer behind it', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-invalid-reply']]);
|
||
const sent = [];
|
||
const invalidNoticeStarted = deferred();
|
||
const releaseInvalidNotice = deferred();
|
||
const answered = deferred();
|
||
let submitted;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => {
|
||
if (message.text === '请用文字回答当前问题。') {
|
||
invalidNoticeStarted.resolve();
|
||
await releaseInvalidNotice.promise;
|
||
}
|
||
sent.push(message);
|
||
}),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'invalid-reply-question',
|
||
rpcId: 'invalid-reply-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'answer', question: '请给出有效文字答案' }],
|
||
},
|
||
respond: async (result) => {
|
||
submitted = result;
|
||
answered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await answered.promise;
|
||
return '有效答案已收到';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('invalid-reply-start', '启动交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请给出有效文字答案')));
|
||
const invalid = bridge.accept(event('invalid-reply-post', '', {
|
||
message_type: 'post',
|
||
content: JSON.stringify({
|
||
content: [
|
||
[{ tag: 'text', text: '这不是文字回答' }],
|
||
[{ tag: 'img', image_key: 'img-test' }],
|
||
],
|
||
}),
|
||
}));
|
||
await invalidNoticeStarted.promise;
|
||
const valid = bridge.accept(event('invalid-reply-valid', '真正的答案'));
|
||
releaseInvalidNotice.resolve();
|
||
|
||
await Promise.all([invalid, valid, first]);
|
||
assert.deepEqual(submitted.value.answer.answers, [{
|
||
id: 'answer',
|
||
selected: [],
|
||
custom: '真正的答案',
|
||
}]);
|
||
assert.equal(sent.at(-1).text, '有效答案已收到');
|
||
});
|
||
|
||
test('an answer resolved elsewhere is not reinterpreted as a later prompt', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-resolved-race']]);
|
||
const originalMarkSeen = fixture.state.markSeen;
|
||
const answerMarkStarted = deferred();
|
||
const releaseAnswerMark = deferred();
|
||
fixture.state.markSeen = async (id) => {
|
||
if (id === 'resolved-answer-first') {
|
||
answerMarkStarted.resolve();
|
||
await releaseAnswerMark.promise;
|
||
}
|
||
await originalMarkSeen(id);
|
||
};
|
||
const sent = [];
|
||
const asked = [];
|
||
const resolved = deferred();
|
||
let resolveInteraction;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text === '后来的普通问题') return '后来问题的回答';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'resolved-race-question',
|
||
rpcId: 'resolved-race-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'answer', question: '可能在其他客户端回答' }],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
});
|
||
resolveInteraction = async () => {
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'resolved-race-question',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
resolved.resolve();
|
||
};
|
||
await resolved.promise;
|
||
return '第一轮已由其他客户端完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('resolved-race-start', '启动外部解决竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const answer = bridge.accept(event('resolved-answer-first', '原本的问题答案'));
|
||
await answerMarkStarted.promise;
|
||
const later = bridge.accept(event('resolved-later-second', '后来的普通问题'));
|
||
await resolveInteraction();
|
||
releaseAnswerMark.resolve();
|
||
|
||
await Promise.all([answer, first, later]);
|
||
assert.deepEqual(asked, ['启动外部解决竞态', '后来的普通问题']);
|
||
assert.equal(asked.includes('原本的问题答案'), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('a late reply to a resolved Feishu question thread is discarded', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-resolved-thread']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const resolved = deferred();
|
||
let resolveInteraction;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'resolved-thread-question',
|
||
rpcId: 'resolved-thread-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'late', question: '稍后会在其他客户端回答' }],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
});
|
||
resolveInteraction = async () => {
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'resolved-thread-question',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
resolved.resolve();
|
||
};
|
||
await resolved.promise;
|
||
return '已由其他客户端完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('resolved-thread-start', '启动线程迟到测试'));
|
||
await eventually(() => typeof resolveInteraction === 'function' && sent.length === 1);
|
||
await resolveInteraction();
|
||
await first;
|
||
await bridge.accept(event('resolved-thread-late', '1', {
|
||
root_id: 'resolved-thread-start',
|
||
parent_id: 'om_test_1',
|
||
thread_id: 'omt_resolved_thread',
|
||
}));
|
||
|
||
assert.deepEqual(asked, ['启动线程迟到测试']);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('a question resolved while its next message is in flight tombstones that late thread', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-resolved-inflight']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const q2SendStarted = deferred();
|
||
const releaseQ2Send = deferred();
|
||
const resolved = deferred();
|
||
let resolveInteraction;
|
||
let nextMessageSequence = 0;
|
||
let q2MessageId;
|
||
const client = {
|
||
im: { v1: { message: { create: async (request) => {
|
||
const messageId = `om_inflight_${++nextMessageSequence}`;
|
||
const outgoing = {
|
||
chatId: request.data.receive_id,
|
||
text: JSON.parse(request.data.content).text,
|
||
messageId,
|
||
};
|
||
sent.push(outgoing);
|
||
if (outgoing.text.includes('在途的第二问')) {
|
||
q2MessageId = messageId;
|
||
q2SendStarted.resolve();
|
||
await releaseQ2Send.promise;
|
||
}
|
||
return { code: 0, data: { message_id: messageId } };
|
||
} } } },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '启动在途解决竞态') return '迟到回答被错误地当成普通 prompt';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'resolved-inflight-question',
|
||
rpcId: 'resolved-inflight-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [
|
||
{ id: 'first', question: '先回答第一问' },
|
||
{ id: 'second', question: '在途的第二问' },
|
||
],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
});
|
||
resolveInteraction = async () => {
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'resolved-inflight-question',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
resolved.resolve();
|
||
};
|
||
await resolved.promise;
|
||
return '已在其他客户端完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('resolved-inflight-start', '启动在途解决竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const firstAnswer = bridge.accept(event('resolved-inflight-first-answer', '第一问答案'));
|
||
await q2SendStarted.promise;
|
||
await resolveInteraction();
|
||
releaseQ2Send.resolve();
|
||
await Promise.all([firstAnswer, first]);
|
||
|
||
await bridge.accept(event('resolved-inflight-late-q2-answer', '第二问的迟到答案', {
|
||
root_id: 'resolved-inflight-start',
|
||
parent_id: q2MessageId,
|
||
thread_id: 'omt_resolved_inflight_q2',
|
||
}));
|
||
|
||
assert.deepEqual(asked, ['启动在途解决竞态']);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('a q2 thread reply accepted before an in-flight send resolves is discarded after resolution', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-resolved-accepted-inflight']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const q2Delivered = deferred();
|
||
const releaseQ2Send = deferred();
|
||
const resolved = deferred();
|
||
let resolveInteraction;
|
||
let nextMessageSequence = 0;
|
||
let q2MessageId;
|
||
const client = {
|
||
im: { v1: { message: { create: async (request) => {
|
||
const messageId = `om_accepted_inflight_${++nextMessageSequence}`;
|
||
const outgoing = {
|
||
chatId: request.data.receive_id,
|
||
text: JSON.parse(request.data.content).text,
|
||
messageId,
|
||
};
|
||
sent.push(outgoing);
|
||
if (outgoing.text.includes('已投递但 Promise 未返回的第二问')) {
|
||
q2MessageId = messageId;
|
||
q2Delivered.resolve();
|
||
await releaseQ2Send.promise;
|
||
}
|
||
return { code: 0, data: { message_id: messageId } };
|
||
} } } },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '启动已接收回复竞态') return '已接收的迟到回复被错误地当成普通 prompt';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'resolved-accepted-inflight-question',
|
||
rpcId: 'resolved-accepted-inflight-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [
|
||
{ id: 'first', question: '先完成第一问' },
|
||
{ id: 'second', question: '已投递但 Promise 未返回的第二问' },
|
||
],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
});
|
||
resolveInteraction = async () => {
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'resolved-accepted-inflight-question',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
resolved.resolve();
|
||
};
|
||
await resolved.promise;
|
||
return '已在其他客户端完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('resolved-accepted-inflight-start', '启动已接收回复竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const firstAnswer = bridge.accept(event(
|
||
'resolved-accepted-inflight-first-answer',
|
||
'第一问答案',
|
||
));
|
||
await q2Delivered.promise;
|
||
|
||
// Feishu has delivered q2 and can emit its thread reply, while the SDK
|
||
// message.create Promise observed by the bridge is still pending.
|
||
const alreadyAcceptedReply = bridge.accept(event(
|
||
'resolved-accepted-inflight-q2-answer',
|
||
'第二问的在途答案',
|
||
{
|
||
root_id: 'resolved-accepted-inflight-start',
|
||
parent_id: q2MessageId,
|
||
thread_id: 'omt_resolved_accepted_inflight_q2',
|
||
},
|
||
));
|
||
await resolveInteraction();
|
||
releaseQ2Send.resolve();
|
||
await Promise.all([alreadyAcceptedReply, firstAnswer, first]);
|
||
|
||
assert.deepEqual(asked, ['启动已接收回复竞态']);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('a recovered orphan question is cancelled without exposing its old content', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-orphan-recovery']]);
|
||
const sent = [];
|
||
let recoveredResponse;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'orphan-secret-question',
|
||
rpcId: 'orphan-secret-question',
|
||
sessionId,
|
||
recovered: true,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'secret', question: '旧会话中的敏感问题内容' }],
|
||
},
|
||
respond: async (result) => {
|
||
recoveredResponse = result;
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
return '新的消息已继续';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('orphan-recovery', '新的会话消息'));
|
||
assert.deepEqual(recoveredResponse, {
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'Feishu safely cancelled an interaction left by an earlier client.',
|
||
details: {},
|
||
},
|
||
});
|
||
assert.equal(sent.some(({ text }) => text.includes('旧会话中的敏感问题内容')), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('遗留的待回答问题')), true);
|
||
assert.equal(sent.at(-1).text, '新的消息已继续');
|
||
});
|
||
|
||
test('a multi-question interaction keeps ordered canonical answers', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-question-batch']]);
|
||
const sent = [];
|
||
const response = deferred();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'batch-question',
|
||
rpcId: 'batch-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [
|
||
{
|
||
id: 'language',
|
||
question: '选择回答语言',
|
||
options: [{ label: '中文' }, { label: 'English' }],
|
||
},
|
||
{
|
||
id: 'deliverables',
|
||
question: '选择交付内容',
|
||
multiSelect: true,
|
||
options: [{ label: '测试' }, { label: '文档' }],
|
||
},
|
||
],
|
||
},
|
||
respond: async (result) => {
|
||
response.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await response.promise;
|
||
return '批量问题已完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('batch-start', '请分步提问'));
|
||
await eventually(() => sent.some(({ text }) => (
|
||
text.includes('(1/2)') && text.includes('选择回答语言')
|
||
)));
|
||
await bridge.accept(event('batch-language', '2'));
|
||
await eventually(() => sent.some(({ text }) => (
|
||
text.includes('(2/2)') && text.includes('选择交付内容')
|
||
)));
|
||
await bridge.accept(event('batch-deliverables', '1,文档,发布说明'));
|
||
|
||
assert.deepEqual(await response.promise, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-question-batch',
|
||
answer: {
|
||
answers: [
|
||
{ id: 'language', selected: ['English'] },
|
||
{ id: 'deliverables', selected: ['测试', '文档'], custom: '发布说明' },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
await first;
|
||
assert.equal(sent.at(-1).text, '批量问题已完成');
|
||
});
|
||
|
||
test('the second answer bypasses the first answer reaction-finalization window', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-multi-window']]);
|
||
const sent = [];
|
||
const asked = [];
|
||
const firstAnswerDoneStarted = deferred();
|
||
const releaseFirstAnswerDone = deferred();
|
||
const submitted = deferred();
|
||
const releaseTurn = deferred();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
channel: {
|
||
addReaction: async (messageId, emojiType) => {
|
||
if (messageId === 'multi-window-first-answer' && emojiType === 'DONE') {
|
||
firstAnswerDoneStarted.resolve();
|
||
await releaseFirstAnswerDone.promise;
|
||
}
|
||
return `reaction-${messageId}-${emojiType}`;
|
||
},
|
||
removeReaction: async () => undefined,
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '启动多问题窗口') return '第二问答案被错误地当成普通 prompt';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'multi-window-question',
|
||
rpcId: 'multi-window-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [
|
||
{
|
||
id: 'first',
|
||
question: '第一问',
|
||
options: [{ label: '甲' }, { label: '乙' }],
|
||
},
|
||
{
|
||
id: 'second',
|
||
question: '第二问',
|
||
options: [{ label: '丙' }, { label: '丁' }],
|
||
},
|
||
],
|
||
},
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
releaseTurn.resolve();
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await releaseTurn.promise;
|
||
return '两个问题均已完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const first = bridge.accept(event('multi-window-start', '启动多问题窗口'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('第一问')));
|
||
const firstAnswer = bridge.accept(event('multi-window-first-answer', '1'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('第二问')));
|
||
await firstAnswerDoneStarted.promise;
|
||
|
||
const secondAnswer = bridge.accept(event('multi-window-second-answer', '2'));
|
||
let submittedResult;
|
||
let deadline;
|
||
try {
|
||
submittedResult = await Promise.race([
|
||
submitted.promise,
|
||
new Promise((_, reject) => {
|
||
deadline = setTimeout(
|
||
() => reject(new Error(
|
||
'the second answer deadlocked behind the first answer DONE reaction',
|
||
)),
|
||
500,
|
||
);
|
||
}),
|
||
]);
|
||
} finally {
|
||
clearTimeout(deadline);
|
||
// Keep the red test from leaving unresolved work behind in the test process.
|
||
releaseFirstAnswerDone.resolve();
|
||
releaseTurn.resolve();
|
||
await Promise.allSettled([firstAnswer, secondAnswer, first]);
|
||
}
|
||
|
||
assert.deepEqual(submittedResult, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-multi-window',
|
||
answer: {
|
||
answers: [
|
||
{ id: 'first', selected: ['甲'] },
|
||
{ id: 'second', selected: ['丁'] },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
assert.deepEqual(asked, ['启动多问题窗口']);
|
||
assert.equal(sent.at(-1).text, '两个问题均已完成');
|
||
});
|
||
|
||
test('a group interaction question tells the user to mention the bot again', async () => {
|
||
const fixture = stateFixture([['group:oc_group_mention', 'session-group-mention']]);
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (message) => sent.push(message)),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the group session should already exist'),
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'group-mention-question',
|
||
rpcId: 'group-mention-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{
|
||
id: 'environment',
|
||
question: '请选择群聊测试环境',
|
||
options: [{ label: '测试环境' }, { label: '生产环境' }],
|
||
}],
|
||
},
|
||
respond: async () => ({ accepted: true }),
|
||
});
|
||
return '群聊提示测试结束';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_a']),
|
||
});
|
||
|
||
await bridge.accept(event('group-mention-start', '@机器人 请先提问', {
|
||
senderOpenId: 'ou_a',
|
||
chat_type: 'group',
|
||
chat_id: 'oc_group_mention',
|
||
mentions: [{ key: '@机器人' }],
|
||
}));
|
||
|
||
const questionText = sent.find(({ text }) => text.includes('请选择群聊测试环境'))?.text;
|
||
assert.match(questionText ?? '', /群聊中请\s*@机器人\s*后发送答案/);
|
||
});
|
||
|
||
test('only the actor who started a group interaction can answer it', async () => {
|
||
const fixture = stateFixture([['group:oc_group_actor', 'session-group-actor']]);
|
||
const asked = [];
|
||
const submitted = deferred();
|
||
let interactionSubmitted = false;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => undefined),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the group session should already exist'),
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '甲发起交互') return '普通群消息已处理';
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'group-actor-question',
|
||
rpcId: 'group-actor-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'actor', question: '只能由甲回答' }],
|
||
},
|
||
respond: async (result) => {
|
||
interactionSubmitted = true;
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await submitted.promise;
|
||
return '甲的交互已完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_a', 'ou_b']),
|
||
});
|
||
|
||
const first = bridge.accept(event('group-actor-start', '甲发起交互', {
|
||
senderOpenId: 'ou_a',
|
||
chat_type: 'group',
|
||
chat_id: 'oc_group_actor',
|
||
}));
|
||
await eventually(() => asked.length === 1);
|
||
const intruder = bridge.accept(event('group-actor-b', '乙试图代答', {
|
||
senderOpenId: 'ou_b',
|
||
chat_type: 'group',
|
||
chat_id: 'oc_group_actor',
|
||
}));
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
assert.equal(interactionSubmitted, false);
|
||
assert.deepEqual(asked, ['甲发起交互']);
|
||
|
||
await bridge.accept(event('group-actor-a', '甲的答案', {
|
||
senderOpenId: 'ou_a',
|
||
chat_type: 'group',
|
||
chat_id: 'oc_group_actor',
|
||
}));
|
||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||
id: 'actor',
|
||
selected: [],
|
||
custom: '甲的答案',
|
||
}]);
|
||
await Promise.all([first, intruder]);
|
||
assert.deepEqual(asked, ['甲发起交互', '乙试图代答']);
|
||
});
|
||
|
||
test('aborting an active Feishu turn removes its processing reaction', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-abort-reaction']]);
|
||
const controller = new AbortController();
|
||
const reactions = [];
|
||
const removed = [];
|
||
const askStarted = deferred();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => undefined),
|
||
channel: {
|
||
addReaction: async (messageId, emojiType) => {
|
||
reactions.push({ messageId, emojiType });
|
||
return `reaction-${emojiType}`;
|
||
},
|
||
removeReaction: async (messageId, reactionId) => removed.push({ messageId, reactionId }),
|
||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
createSession: async () => assert.fail('the existing session should be reused'),
|
||
ask: async (_sessionId, _text, options) => {
|
||
askStarted.resolve();
|
||
await new Promise((resolve, reject) => {
|
||
if (options.signal.aborted) {
|
||
reject(options.signal.reason);
|
||
return;
|
||
}
|
||
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
||
});
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
const processing = bridge.accept(event('abort-reaction', '启动后停止'));
|
||
await askStarted.promise;
|
||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||
await processing;
|
||
|
||
assert.deepEqual(reactions, [{ messageId: 'abort-reaction', emojiType: 'OnIt' }]);
|
||
assert.deepEqual(removed, [{
|
||
messageId: 'abort-reaction',
|
||
reactionId: 'reaction-OnIt',
|
||
}]);
|
||
});
|
||
|
||
test('reaction failures do not block streaming replies', async () => {
|
||
const seen = new Set();
|
||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {},
|
||
channel: {
|
||
addReaction: async () => { throw new Error('reaction unavailable'); },
|
||
removeReaction: async () => undefined,
|
||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onUpdate({ type: 'tool', name: 'web_search' });
|
||
return '天气结果';
|
||
},
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: () => 'session-existing',
|
||
},
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_reaction_failure', '深圳天气'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(status.messagesReplied, 1);
|
||
await eventually(() => status.reactionErrors === 2);
|
||
assert.equal(status.reactionErrors, 2);
|
||
assert.equal(status.streamResponses, 1);
|
||
});
|
||
|
||
test('a hanging Feishu reaction does not delay the message promise or the next queued turn', async () => {
|
||
const seen = new Set();
|
||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||
let asks = 0;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {},
|
||
channel: {
|
||
addReaction: () => new Promise(() => {}),
|
||
removeReaction: async () => undefined,
|
||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => `正常回答 ${++asks}`,
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: () => 'session-existing',
|
||
},
|
||
status,
|
||
logger: { debug() {}, error() {}, warn() {} },
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await Promise.race([
|
||
Promise.all([
|
||
bridge.accept(event('reaction-hang-one', '第一条')),
|
||
bridge.accept(event('reaction-hang-two', '第二条')),
|
||
]),
|
||
new Promise((_, reject) => setTimeout(
|
||
() => reject(new Error('Feishu message flow waited for a reaction request')),
|
||
100,
|
||
)),
|
||
]);
|
||
|
||
assert.equal(asks, 2);
|
||
assert.equal(status.messagesReplied, 2);
|
||
});
|
||
|
||
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||
const scenarios = [
|
||
{
|
||
name: 'native image',
|
||
fileName: 'native.png',
|
||
content: PNG_1X1,
|
||
expectedCalls: ['image'],
|
||
expectedPresentation: 'feishu-image',
|
||
expectedProviderIds: ['om-native-image'],
|
||
},
|
||
{
|
||
name: 'ordinary file',
|
||
fileName: 'ordinary.txt',
|
||
content: 'ordinary file',
|
||
expectedCalls: ['file'],
|
||
expectedPresentation: 'feishu-file',
|
||
expectedProviderIds: ['om-native-file'],
|
||
},
|
||
{
|
||
name: 'definite image rejection falls back',
|
||
fileName: 'fallback.png',
|
||
content: PNG_1X1,
|
||
imageError: 'artifact-provider-rejected',
|
||
expectedCalls: ['image', 'file'],
|
||
expectedPresentation: 'feishu-file',
|
||
expectedProviderIds: ['om-native-file'],
|
||
},
|
||
{
|
||
name: 'uncertain image never falls back',
|
||
fileName: 'uncertain.png',
|
||
content: PNG_1X1,
|
||
imageError: 'artifact-delivery-uncertain',
|
||
expectedCalls: ['image'],
|
||
expectedPresentation: 'text-fallback',
|
||
expectedProviderIds: [],
|
||
expectedOutcome: 'unknown',
|
||
},
|
||
];
|
||
|
||
for (const [index, scenario] of scenarios.entries()) {
|
||
await t.test(scenario.name, async (subtest) => {
|
||
const artifact = await committedArtifact(
|
||
subtest,
|
||
scenario.fileName,
|
||
scenario.content,
|
||
`bridge-image-route-${index}`,
|
||
);
|
||
const calls = [];
|
||
const status = bridgeStatus();
|
||
const resultFor = (file, presentation, providerMessageId) => ({
|
||
schemaVersion: 1,
|
||
deliveryId: file.deliveryKey,
|
||
presentation,
|
||
providerMessageIds: [providerMessageId],
|
||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||
});
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => { throw new Error('text intentionally unavailable'); }),
|
||
channel: {
|
||
addReaction: async () => 'reaction',
|
||
removeReaction: async () => undefined,
|
||
sendImage: async (chatId, file, options) => {
|
||
calls.push('image');
|
||
assert.equal(chatId, 'oc_chat');
|
||
assert.equal(file.fileName, scenario.fileName);
|
||
assert.equal(file.mediaType, 'image/png');
|
||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||
if (scenario.imageError) {
|
||
const error = new Error('private image result');
|
||
error.code = scenario.imageError;
|
||
throw error;
|
||
}
|
||
return resultFor(file, 'feishu-image', 'om-native-image');
|
||
},
|
||
sendFile: async (chatId, file, options) => {
|
||
calls.push('file');
|
||
assert.equal(chatId, 'oc_chat');
|
||
assert.equal(file.fileName, scenario.fileName);
|
||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||
return resultFor(file, 'feishu-file', 'om-native-file');
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '';
|
||
},
|
||
},
|
||
state: stateFixture([
|
||
['p2p:ou_user', `session-feishu-image-route-${index}`],
|
||
]).state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
const receipt = await bridge.accept(event(`om-feishu-image-route-${index}`, '生成产物'));
|
||
|
||
assert.deepEqual(calls, scenario.expectedCalls);
|
||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||
assert.deepEqual(receipt.artifacts, [{
|
||
artifactId: artifact.artifactId,
|
||
outcome: scenario.expectedOutcome ?? 'sent',
|
||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||
? { reason: 'artifact-delivery-uncertain' }
|
||
: {}),
|
||
}]);
|
||
assert.equal(status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||
assert.equal(status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('Feishu finalizes the answer card before delivering registered result files and reports partial failure', async (t) => {
|
||
const html = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'html');
|
||
const generic = await committedArtifact(t, 'notes.txt', 'notes', 'notes');
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-artifacts']]);
|
||
const order = [];
|
||
const delivered = [];
|
||
const notices = [];
|
||
const status = bridgeStatus();
|
||
const abort = new AbortController();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => notices.push(text)),
|
||
channel: {
|
||
addReaction: async () => 'reaction',
|
||
removeReaction: async () => undefined,
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async () => undefined });
|
||
order.push('card-finalized');
|
||
return { messageId: 'om-card' };
|
||
},
|
||
sendFile: async (chatId, file, options) => {
|
||
order.push(`file:${file.fileName}`);
|
||
delivered.push({ chatId, file, options });
|
||
if (file.fileName === 'notes.txt') {
|
||
const error = new Error('provider detail must stay private');
|
||
error.code = 'artifact-rate-limited';
|
||
throw error;
|
||
}
|
||
return {
|
||
schemaVersion: 1,
|
||
deliveryId: file.deliveryKey,
|
||
presentation: 'feishu-file',
|
||
providerMessageIds: ['om-file'],
|
||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||
};
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(html);
|
||
await options.onArtifact(generic);
|
||
return '两个结果文件已经生成。';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
signal: abort.signal,
|
||
});
|
||
|
||
bridge.accept(event('om_artifacts', '生成 HTML 和说明文件并发给我'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(order, ['card-finalized', 'file:result.html', 'file:notes.txt']);
|
||
assert.equal(delivered[0].chatId, 'oc_chat');
|
||
assert.deepEqual(delivered[0].options, {
|
||
replyTo: 'om_artifacts',
|
||
signal: abort.signal,
|
||
});
|
||
assert.equal(delivered[0].file.bytes.toString(), '<h1>result</h1>');
|
||
assert.equal(delivered[1].file.bytes.toString(), 'notes');
|
||
assert.equal(status.artifactsSent, 1);
|
||
assert.equal(status.artifactSendErrors, 1);
|
||
assert.equal(notices.length, 1);
|
||
assert.match(notices[0], /notes\.txt.*限流/);
|
||
assert.equal(status.lastMessageError.code, 'CHANNEL_RATE_LIMIT');
|
||
assert.equal(status.lastMessageError.reason, 'ARTIFACT_RATE_LIMITED');
|
||
assert.equal(notices[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||
assert.doesNotMatch(notices[0], /provider detail/);
|
||
});
|
||
|
||
test('Feishu tells users to check the chat before retrying an uncertain file delivery', async (t) => {
|
||
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
|
||
const notices = [];
|
||
const status = bridgeStatus();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => notices.push(text)),
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async () => undefined });
|
||
return { messageId: 'om-uncertain-card' };
|
||
},
|
||
sendFile: async () => {
|
||
const error = new Error('private transport detail');
|
||
error.code = 'artifact-delivery-uncertain';
|
||
throw error;
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '结果已生成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(notices.length, 1);
|
||
assert.match(notices[0], /^结果文件「uncertain\.txt」发送结果未能确认/);
|
||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||
assert.equal(status.lastMessageError.reason, 'ARTIFACT_DELIVERY_UNCERTAIN');
|
||
assert.equal(notices[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||
});
|
||
|
||
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
|
||
const artifact = await committedArtifact(t, 'file-only.txt', 'file only', 'file-only');
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-file-only']]);
|
||
const cardContents = [];
|
||
const files = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => undefined),
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async (content) => cardContents.push(content) });
|
||
return { messageId: 'om-file-only-card' };
|
||
},
|
||
sendFile: async (_chatId, file) => {
|
||
files.push(file.fileName);
|
||
return {
|
||
schemaVersion: 1,
|
||
deliveryId: file.deliveryKey,
|
||
presentation: 'feishu-file',
|
||
providerMessageIds: ['om-file-only'],
|
||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||
};
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_file_only', '只发送结果文件'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(cardContents, ['结果文件已生成。']);
|
||
assert.deepEqual(files, ['file-only.txt']);
|
||
});
|
||
|
||
test('a CardKit finalization failure falls back to text and delivers each artifact once without a second prompt', async (t) => {
|
||
const artifact = await committedArtifact(t, 'fallback.txt', 'fallback result', 'fallback');
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-fallback-artifact']]);
|
||
const sent = [];
|
||
const files = [];
|
||
let asks = 0;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async () => undefined });
|
||
throw new Error('card finalization failed');
|
||
},
|
||
sendFile: async (_chatId, file) => {
|
||
files.push(file.fileName);
|
||
return {
|
||
schemaVersion: 1,
|
||
deliveryId: file.deliveryKey,
|
||
presentation: 'feishu-file',
|
||
providerMessageIds: ['om-fallback-file'],
|
||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||
};
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
asks += 1;
|
||
await options.onArtifact(artifact);
|
||
return '回答已生成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_artifact_card_fallback', '生成并发送文件'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(asks, 1);
|
||
assert.deepEqual(sent, ['回答已生成']);
|
||
assert.deepEqual(files, ['fallback.txt']);
|
||
});
|
||
|
||
test('Feishu still delivers a file-only result when CardKit and fallback text both fail', async (t) => {
|
||
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
|
||
const files = [];
|
||
let fallbackTextAttempts = 0;
|
||
const status = bridgeStatus();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => {
|
||
fallbackTextAttempts += 1;
|
||
throw new Error('text transport unavailable');
|
||
}),
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async () => undefined });
|
||
throw new Error('card finalization unavailable');
|
||
},
|
||
sendFile: async (_chatId, file) => {
|
||
files.push(file.fileName);
|
||
return {
|
||
schemaVersion: 1,
|
||
deliveryId: file.deliveryKey,
|
||
presentation: 'feishu-file',
|
||
providerMessageIds: ['om-file-after-text-failure'],
|
||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||
};
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
bridge.accept(event('om_file_after_text_failure', '只生成文件'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(files, ['survives-text-failure.txt']);
|
||
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
|
||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||
});
|
||
|
||
test('Feishu returns the receipt independently and emits one safe notice when text and file delivery fail', async (t) => {
|
||
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
|
||
await t.test(errorCode, async (subtest) => {
|
||
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
|
||
const attemptedTexts = [];
|
||
const visibleTexts = [];
|
||
const reactions = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {
|
||
im: { v1: { message: { create: async (request) => {
|
||
const text = JSON.parse(request.data.content).text;
|
||
attemptedTexts.push(text);
|
||
if (text === '文字结果') throw new Error('text transport unavailable');
|
||
visibleTexts.push(text);
|
||
return { code: 0, data: {} };
|
||
} } } },
|
||
},
|
||
channel: {
|
||
addReaction: async (_messageId, emoji) => {
|
||
reactions.push(emoji);
|
||
return `reaction-${emoji}`;
|
||
},
|
||
removeReaction: async () => undefined,
|
||
sendFile: async () => {
|
||
const error = new Error('unsafe result file');
|
||
error.code = errorCode;
|
||
throw error;
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '文字结果';
|
||
},
|
||
},
|
||
state: stateFixture([['p2p:ou_user', `session-${errorCode}`]]).state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
const receipt = await bridge.accept(event(`om-${errorCode}`, '生成并发送文件'));
|
||
|
||
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
|
||
assert.equal(visibleTexts.length, 1);
|
||
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||
assert.deepEqual(reactions, ['OnIt', 'DONE']);
|
||
assert.deepEqual(receipt, {
|
||
schemaVersion: 1,
|
||
deliveryId: artifact.deliveryKey,
|
||
presentation: 'text-fallback',
|
||
providerMessageIds: [],
|
||
artifacts: [{
|
||
artifactId: artifact.artifactId,
|
||
outcome: 'rejected',
|
||
reason: errorCode,
|
||
}],
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
test('Feishu keeps the generic error when no answer or file failure notice is visible', async (t) => {
|
||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||
const attemptedTexts = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => {
|
||
attemptedTexts.push(text);
|
||
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
|
||
}),
|
||
channel: {
|
||
sendFile: async () => {
|
||
const error = new Error('file transport unavailable');
|
||
error.code = 'artifact-provider-failed';
|
||
throw error;
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '文字结果';
|
||
},
|
||
},
|
||
state: stateFixture([['p2p:ou_user', 'session-no-visible-failure']]).state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
|
||
|
||
assert.equal(attemptedTexts.length, 3);
|
||
assert.match(attemptedTexts.at(-1), /^回复发送结果未能确认/);
|
||
assert.match(attemptedTexts.at(-1), /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
|
||
});
|
||
|
||
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
|
||
const artifact = await committedArtifact(t, 'cancel-after-card.txt', 'file bytes', 'cancel-after-card');
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-cancel-after-card']]);
|
||
const controller = new AbortController();
|
||
const fallbackTexts = [];
|
||
let files = 0;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => fallbackTexts.push(text)),
|
||
channel: {
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async () => undefined });
|
||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||
return { messageId: 'om-final-card' };
|
||
},
|
||
sendFile: async () => {
|
||
files += 1;
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '卡片已经完成';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
signal: controller.signal,
|
||
logger: { info() {}, warn() {}, error() {} },
|
||
});
|
||
|
||
bridge.accept(event('om_cancel_after_card', '生成文件'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(files, 0);
|
||
assert.deepEqual(fallbackTexts, []);
|
||
});
|
||
|
||
test('Feishu delivers oversized native streams once and retains every card id in the receipt', async () => {
|
||
const fixture = stateFixture([['p2p:ou_user', 'session-long-answer']]);
|
||
const status = bridgeStatus();
|
||
const cards = new Map();
|
||
const replyIds = [];
|
||
const recalls = [];
|
||
const fallbackTexts = [];
|
||
const answer = `${'完整回答'.repeat(15000)}\n完成。`;
|
||
let askCount = 0;
|
||
const client = textClient(async ({ text }) => fallbackTexts.push(text));
|
||
client.cardkit = { v1: {
|
||
card: {
|
||
create: async (request) => {
|
||
const cardId = `card-${cards.size + 1}`;
|
||
cards.set(cardId, { content: JSON.parse(request.data.data).body.elements[0].content });
|
||
return { code: 0, data: { card_id: cardId } };
|
||
},
|
||
settings: async ({ path, data }) => {
|
||
cards.get(path.card_id).finished = !JSON.parse(data.settings).config.streaming_mode;
|
||
return { code: 0 };
|
||
},
|
||
},
|
||
cardElement: { content: async ({ path, data }) => {
|
||
assert.ok(data.content.length <= 28000);
|
||
cards.get(path.card_id).content = data.content;
|
||
return { code: 0 };
|
||
} },
|
||
} };
|
||
client.im.v1.message.reply = async ({ path }) => {
|
||
assert.equal(path.message_id, 'om-long-answer');
|
||
const messageId = `om-card-${replyIds.length + 1}`;
|
||
replyIds.push(messageId);
|
||
return { code: 0, data: { message_id: messageId } };
|
||
};
|
||
client.im.v1.message.delete = async ({ path }) => {
|
||
recalls.push(path.message_id);
|
||
return { code: 0 };
|
||
};
|
||
client.im.v1.messageReaction = {
|
||
create: async () => ({ code: 0, data: { reaction_id: 'reaction-long-answer' } }),
|
||
delete: async () => ({ code: 0 }),
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: new VerifiedFeishuChannel({ client }),
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
askCount += 1;
|
||
await options.onUpdate({ type: 'text', text: '中间过程'.repeat(8000) });
|
||
await options.onUpdate({ type: 'tool', name: 'web_search' });
|
||
await options.onUpdate({ type: 'text', text: answer.slice(0, 28001) });
|
||
return answer;
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
const receipt = await bridge.accept(event('om-long-answer', '请生成长回答'));
|
||
|
||
assert.equal(askCount, 1);
|
||
assert.equal([...cards.values()].map(({ content }) => content).join(''), answer);
|
||
assert.ok([...cards.values()].every(({ finished }) => finished));
|
||
assert.deepEqual(receipt.providerMessageIds, ['om-card-1', 'om-card-2', 'om-card-3']);
|
||
assert.deepEqual(recalls, []);
|
||
assert.deepEqual(fallbackTexts, []);
|
||
assert.equal(status.streamResponses, 1);
|
||
assert.equal(status.streamErrors ?? 0, 0);
|
||
assert.equal(status.streamFallbacks ?? 0, 0);
|
||
});
|
||
|
||
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
|
||
const seen = new Set();
|
||
const sent = [];
|
||
const finalReactions = [];
|
||
let askCount = 0;
|
||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {
|
||
im: { v1: { message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0 };
|
||
} } } },
|
||
},
|
||
channel: {
|
||
addReaction: async (_messageId, emojiType) => {
|
||
finalReactions.push(emojiType);
|
||
return `reaction-${emojiType}`;
|
||
},
|
||
removeReaction: async () => undefined,
|
||
stream: async (_chatId, input) => {
|
||
await input.markdown({ setContent: async () => undefined });
|
||
throw new Error('card finalization failed');
|
||
},
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
askCount += 1;
|
||
return '已经生成的最终回答';
|
||
},
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: () => 'session-existing',
|
||
},
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_stream_finalize_failure', '不要重复提交'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(askCount, 1);
|
||
assert.deepEqual(sent, ['已经生成的最终回答']);
|
||
assert.deepEqual(finalReactions, ['OnIt', 'DONE']);
|
||
assert.equal(status.messagesReplied, 1);
|
||
assert.equal(status.streamFallbacks, 1);
|
||
assert.equal(status.streamErrors, 1);
|
||
});
|
||
|
||
test('bridge does not expose internal error details in a Feishu failure reply', async () => {
|
||
const sent = [];
|
||
const seen = new Set();
|
||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: {
|
||
im: { v1: { message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0 };
|
||
} } } },
|
||
},
|
||
channel: {
|
||
addReaction: async (_messageId, emojiType) => `reaction-${emojiType}`,
|
||
removeReaction: async () => undefined,
|
||
},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
throw new Error('secret-shaped-internal-detail /private/path');
|
||
},
|
||
},
|
||
state: {
|
||
hasSeen: (id) => seen.has(id),
|
||
markSeen: async (id) => seen.add(id),
|
||
sessionFor: () => 'session-existing',
|
||
},
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
bridge.accept(event('om_internal_failure', '触发错误'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(sent.length, 1);
|
||
assert.match(sent[0], /任务未完成,暂时无法确定原因/);
|
||
assert.match(sent[0], /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
|
||
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
||
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
||
});
|
||
|
||
test('Feishu exposes a structured model rate limit without changing connection state', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-rate-limit']]);
|
||
const sent = [];
|
||
const status = {
|
||
...bridgeStatus(),
|
||
connected: true,
|
||
connectionState: 'connected',
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async (outgoing) => sent.push(outgoing.text)),
|
||
channel: {},
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
const error = new Error('private Feishu provider rate-limit detail');
|
||
error.code = 'harness-turn-failed';
|
||
error.providerCode = 'RATE_LIMIT';
|
||
throw error;
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
logger: { error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('om_rate_limit', '触发模型限流', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await bridge.waitForIdle();
|
||
|
||
const failure = status.lastMessageError;
|
||
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
|
||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
|
||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
|
||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
|
||
assert.doesNotMatch(sent.at(-1), /private Feishu provider rate-limit detail/);
|
||
assert.equal(status.connected, true);
|
||
assert.equal(status.connectionState, 'connected');
|
||
});
|
||
|
||
// ── Interactive cards: menus, session lists, workspace lists ───────────────
|
||
|
||
function cardClient(onSend, onPatch = null) {
|
||
let sequence = 0;
|
||
const client = {
|
||
im: { v1: { message: { create: async (request) => {
|
||
const outgoing = {
|
||
chatId: request.data.receive_id,
|
||
msgType: request.data.msg_type,
|
||
content: request.data.msg_type === 'interactive'
|
||
? JSON.parse(request.data.content)
|
||
: request.data.content,
|
||
};
|
||
await onSend(outgoing);
|
||
sequence += 1;
|
||
return { code: 0, data: { message_id: `om_card_${sequence}` } };
|
||
} } } },
|
||
};
|
||
if (typeof onPatch === 'function') {
|
||
client.im.v1.message.patch = async (request) => (
|
||
await onPatch(request) ?? { code: 0 }
|
||
);
|
||
}
|
||
return client;
|
||
}
|
||
|
||
function cardActionEvent(messageId, action, operatorOpenId) {
|
||
return {
|
||
operator: { open_id: operatorOpenId },
|
||
action: { value: { action } },
|
||
context: { open_message_id: messageId },
|
||
};
|
||
}
|
||
|
||
function buttonsFromCard(content) {
|
||
const buttons = [];
|
||
const visit = (value) => {
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) visit(item);
|
||
return;
|
||
}
|
||
if (!value || typeof value !== 'object') return;
|
||
if (value.tag === 'button') buttons.push(value);
|
||
for (const child of Object.values(value)) visit(child);
|
||
};
|
||
visit(content.body?.elements);
|
||
return buttons;
|
||
}
|
||
|
||
function selectsFromCard(content) {
|
||
const selects = [];
|
||
const visit = (value) => {
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) visit(item);
|
||
return;
|
||
}
|
||
if (!value || typeof value !== 'object') return;
|
||
if (value.tag === 'select_static') selects.push(value);
|
||
for (const child of Object.values(value)) visit(child);
|
||
};
|
||
visit(content.body?.elements);
|
||
return selects;
|
||
}
|
||
|
||
function callbackAction(button) {
|
||
return button.behaviors?.find((behavior) => behavior?.type === 'callback')?.value?.action;
|
||
}
|
||
|
||
function useActionsFromCard(content) {
|
||
return buttonsFromCard(content)
|
||
.map(callbackAction)
|
||
.filter((action) => typeof action === 'string' && action.startsWith('use:'))
|
||
.map((action) => action.slice('use:'.length));
|
||
}
|
||
|
||
function sessionsHarness(count) {
|
||
const workspace = join(tmpdir(), 'dsh-im-card-test-work');
|
||
mkdirSync(workspace, { recursive: true });
|
||
const sessions = Array.from({ length: count }, (_, index) => ({
|
||
sessionId: `session-${String(index + 1).padStart(2, '0')}`,
|
||
title: `Session ${index + 1}`,
|
||
}));
|
||
return {
|
||
ensureRunning: async () => true,
|
||
currentWorkspace: () => workspace,
|
||
listWorkspaceSessions: async () => ({ workspace, sessions }),
|
||
listWorkspaces: async () => [workspace],
|
||
bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: `Session ${sessionId}` }),
|
||
switchWorkspace: async (path) => path,
|
||
};
|
||
}
|
||
|
||
test('card buttons from an unallowed sender are ignored', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ chatId, msgType, content }) => {
|
||
sent.push({ chatId, msgType, content });
|
||
}),
|
||
channel: {},
|
||
harness: sessionsHarness(3),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.length, 1);
|
||
assert.equal(fixture.sessions.size, 0);
|
||
|
||
// A group member outside the allowlist clicks "new session" on the card.
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_evil'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(fixture.sessions.size, 0, 'unallowed card operator must not act');
|
||
|
||
await bridge.onCardAction({
|
||
action: { value: { action: 'new' } },
|
||
context: { open_message_id: 'om_card_1' },
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.length, 1, 'a card action without an operator must fail closed');
|
||
});
|
||
|
||
test('card buttons from an allowed sender work', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ chatId, msgType, content }) => {
|
||
sent.push({ chatId, msgType, content });
|
||
}),
|
||
channel: {},
|
||
harness: sessionsHarness(3),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-open-2', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.length, 1);
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.length, 3, 'allowed operator click should send a reply + menu card update');
|
||
});
|
||
|
||
test('card buttons honor the wildcard sender allowlist', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness: sessionsHarness(3),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['*']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-open-wildcard', '/m', { senderOpenId: 'ou_any_user' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'status', 'ou_another_user'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(sent.length, 2, 'wildcard access must apply to card callbacks too');
|
||
});
|
||
|
||
test('card refresh updates the callback message instead of a newer card', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const patches = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async (outgoing) => sent.push(outgoing),
|
||
async (request) => patches.push(request),
|
||
),
|
||
channel: {},
|
||
harness: sessionsHarness(3),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-first', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.accept(event('menu-second', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards(sent).length, 2);
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(patches.length, 1);
|
||
assert.equal(patches[0].path.message_id, 'om_card_1');
|
||
assert.equal(typeof patches[0].data.content, 'string');
|
||
assert.equal(cards(sent).length, 2, 'a successful refresh must not create another card');
|
||
});
|
||
|
||
test('duplicate in-flight card callbacks share one side effect and one refresh', async () => {
|
||
const fixture = stateFixture();
|
||
let archiveVisible = false;
|
||
let archiveWrites = 0;
|
||
fixture.state.includesArchivedSessions = () => archiveVisible;
|
||
fixture.state.setIncludeArchivedSessions = async (next) => {
|
||
archiveWrites += 1;
|
||
archiveVisible = next;
|
||
};
|
||
const patches = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async () => {},
|
||
async (request) => patches.push(request),
|
||
),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-dedupe-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const callback = cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner');
|
||
const first = bridge.onCardAction(callback);
|
||
const retry = bridge.onCardAction(structuredClone(callback));
|
||
|
||
assert.equal(first, retry, 'a provider retry must join the original callback task');
|
||
await Promise.all([first, retry]);
|
||
await bridge.waitForIdle();
|
||
assert.equal(archiveWrites, 1);
|
||
assert.equal(archiveVisible, true);
|
||
assert.equal(patches.length, 1);
|
||
});
|
||
|
||
test('a settled provider event retry does not repeat its card side effect', async () => {
|
||
const fixture = stateFixture();
|
||
let archiveVisible = false;
|
||
let archiveWrites = 0;
|
||
fixture.state.includesArchivedSessions = () => archiveVisible;
|
||
fixture.state.setIncludeArchivedSessions = async (next) => {
|
||
archiveWrites += 1;
|
||
archiveVisible = next;
|
||
};
|
||
const patches = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async () => {}, async (request) => patches.push(request)),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-settled-dedupe-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const callback = {
|
||
...cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'),
|
||
event_id: 'evt_archive_once',
|
||
};
|
||
await bridge.onCardAction(callback);
|
||
await bridge.onCardAction(structuredClone(callback));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(archiveWrites, 1);
|
||
assert.equal(archiveVisible, true);
|
||
assert.equal(patches.length, 1);
|
||
});
|
||
|
||
test('legacy token does not suppress a legitimate settled repeat action', async () => {
|
||
const fixture = stateFixture();
|
||
let archiveVisible = false;
|
||
let archiveWrites = 0;
|
||
fixture.state.includesArchivedSessions = () => archiveVisible;
|
||
fixture.state.setIncludeArchivedSessions = async (next) => {
|
||
archiveWrites += 1;
|
||
archiveVisible = next;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async () => {}, async () => ({ code: 0 })),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-legacy-settled-dedupe', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const callback = {
|
||
...cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'),
|
||
token: 'legacy-verification-or-refresh-token',
|
||
};
|
||
await bridge.onCardAction(callback);
|
||
await bridge.onCardAction(structuredClone(callback));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(archiveWrites, 2);
|
||
assert.equal(archiveVisible, false);
|
||
});
|
||
|
||
test('a settled legacy callback retry deduplicates by uuid', async () => {
|
||
const fixture = stateFixture();
|
||
let archiveVisible = false;
|
||
let archiveWrites = 0;
|
||
fixture.state.includesArchivedSessions = () => archiveVisible;
|
||
fixture.state.setIncludeArchivedSessions = async (next) => {
|
||
archiveWrites += 1;
|
||
archiveVisible = next;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async () => {}, async () => ({ code: 0 })),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-legacy-uuid-dedupe', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const callback = {
|
||
...cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'),
|
||
uuid: 'legacy-card-event-uuid',
|
||
token: 'delayed-update-token',
|
||
};
|
||
await bridge.onCardAction(callback);
|
||
await bridge.onCardAction(structuredClone(callback));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(archiveWrites, 1);
|
||
assert.equal(archiveVisible, true);
|
||
});
|
||
|
||
test('card callback dedupe keeps two allowed operators isolated', async () => {
|
||
const fixture = stateFixture();
|
||
let archiveVisible = false;
|
||
let archiveWrites = 0;
|
||
fixture.state.includesArchivedSessions = () => archiveVisible;
|
||
fixture.state.setIncludeArchivedSessions = async (next) => {
|
||
archiveWrites += 1;
|
||
archiveVisible = next;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async () => {}, async () => ({ code: 0 })),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['*']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-operator-isolation', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction({
|
||
...cardActionEvent('om_card_1', 'archive_toggle', 'ou_one'),
|
||
event_id: 'evt_shared_for_test',
|
||
});
|
||
await bridge.onCardAction({
|
||
...cardActionEvent('om_card_1', 'archive_toggle', 'ou_two'),
|
||
event_id: 'evt_shared_for_test',
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(archiveWrites, 2);
|
||
assert.equal(archiveVisible, false);
|
||
});
|
||
|
||
test('single-select workspace ids preserve commas', async () => {
|
||
const fixture = stateFixture();
|
||
const workspace = join(tmpdir(), `dsh-im,workspace-${Math.random().toString(36).slice(2)}`);
|
||
mkdirSync(workspace, { recursive: true });
|
||
const selected = [];
|
||
const harness = {
|
||
...sessionsHarness(1),
|
||
currentWorkspace: () => workspace,
|
||
listWorkspaces: async () => [workspace],
|
||
listWorkspaceSessions: async () => ({ workspace, sessions: [] }),
|
||
switchWorkspace: async (value) => {
|
||
selected.push(value);
|
||
return value;
|
||
},
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async () => {}, async () => ({ code: 0 })),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-comma-workspace', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction({
|
||
operator: { open_id: 'ou_owner' },
|
||
action: { value: { action: 'workspace_pick' }, option: workspace },
|
||
context: { open_message_id: 'om_card_1' },
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(selected, [workspace]);
|
||
});
|
||
|
||
test('menu optional Host data is bounded by the card data timeout', async () => {
|
||
const fixture = stateFixture();
|
||
const waitForAbort = ({ signal }) => new Promise((resolve, reject) => {
|
||
if (signal.aborted) reject(signal.reason);
|
||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||
});
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness: {
|
||
currentWorkspace: () => null,
|
||
listWorkspaces: waitForAbort,
|
||
agentPresetSettings: waitForAbort,
|
||
listModels: waitForAbort,
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
cardDataTimeoutMs: 20,
|
||
});
|
||
|
||
const startedAt = Date.now();
|
||
await bridge.accept(event('menu-bounded-data', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(cards(sent).length, 1);
|
||
assert.ok(Date.now() - startedAt < 500, 'menu must degrade instead of waiting for Host RPC timeouts');
|
||
});
|
||
|
||
test('a Feishu PATCH business error falls back to a new card', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async (outgoing) => sent.push(outgoing),
|
||
async () => ({ code: 230099, msg: 'parse json error' }),
|
||
),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('menu-patch-error', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'));
|
||
|
||
assert.equal(cards(sent).length, 2, 'business failure must create a usable replacement card');
|
||
});
|
||
|
||
test('an unbound menu leaves recent sessions unselected', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness: sessionsHarness(3),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-unbound', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
|
||
const sessionPick = selectsFromCard(cards(sent).at(-1).content)
|
||
.find((select) => select.name === 'session_pick');
|
||
assert.ok(sessionPick, 'recent sessions should remain selectable');
|
||
assert.equal(sessionPick.initial_index, 0, 'no session is selected until the user binds one');
|
||
assert.deepEqual(
|
||
sessionPick.options.map((option) => option.value),
|
||
['session-01', 'session-02', 'session-03'],
|
||
);
|
||
assert.equal(
|
||
sessionPick.options.some((option) => option.text?.content?.includes('✓')),
|
||
false,
|
||
'an unbound chat must not display a fake current session',
|
||
);
|
||
});
|
||
|
||
test('a bound menu reuses one session listing for its title and dropdown', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-01']]);
|
||
const sent = [];
|
||
const harness = sessionsHarness(3);
|
||
let listCalls = 0;
|
||
const listWorkspaceSessions = harness.listWorkspaceSessions;
|
||
harness.listWorkspaceSessions = async (...args) => {
|
||
listCalls += 1;
|
||
return listWorkspaceSessions(...args);
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-bound-one-list', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(listCalls, 1, 'menu must not scan the same workspace twice');
|
||
const sessionPick = selectsFromCard(cards(sent).at(-1).content)
|
||
.find((select) => select.name === 'session_pick');
|
||
assert.equal(sessionPick.options[0].value, 'session-01');
|
||
assert.match(sessionPick.options[0].text.content, /Session 1/);
|
||
});
|
||
|
||
test('compact card action contains session lookup failures', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let failSessionLookup = false;
|
||
const status = bridgeStatus();
|
||
const sessionFor = fixture.state.sessionFor;
|
||
fixture.state.sessionFor = (key) => {
|
||
if (failSessionLookup) {
|
||
const error = new Error('secret-shaped compact detail /private/path');
|
||
error.code = 'harness-turn-failed';
|
||
error.providerCode = 'RATE_LIMIT';
|
||
throw error;
|
||
}
|
||
return sessionFor(key);
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness: sessionsHarness(1),
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('compact-card-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
failSessionLookup = true;
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'compact', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
const replies = sent
|
||
.filter((message) => message.msgType === 'text')
|
||
.map((message) => JSON.parse(message.content).text);
|
||
assert.equal(replies.length, 1);
|
||
assert.match(replies[0], /模型服务正在限流,本次任务未完成。请稍后重试。/);
|
||
assert.match(replies[0], /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
|
||
assert.equal(replies[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path|compactCommand/);
|
||
});
|
||
|
||
test('Feishu list and status command failures share one safe classified format', async () => {
|
||
for (const command of ['/sessionlist', '/workspacelist', '/status']) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const status = bridgeStatus();
|
||
const providerFailure = () => {
|
||
const error = new Error(`private ${command} provider detail`);
|
||
error.code = 'harness-turn-failed';
|
||
error.providerCode = 'RATE_LIMIT';
|
||
return error;
|
||
};
|
||
const harness = {
|
||
currentWorkspace: () => tmpdir(),
|
||
listWorkspaces: async () => [tmpdir()],
|
||
listWorkspaceSessions: async () => ({ workspace: tmpdir(), sessions: [] }),
|
||
ensureRunning: async () => true,
|
||
};
|
||
if (command === '/sessionlist') harness.listWorkspaceSessions = async () => { throw providerFailure(); };
|
||
if (command === '/workspacelist') harness.listWorkspaces = async () => { throw providerFailure(); };
|
||
if (command === '/status') harness.ensureRunning = async () => { throw providerFailure(); };
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status,
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event(`classified-${command.slice(1)}`, command));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT', command);
|
||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/, command);
|
||
assert.equal(
|
||
sent.at(-1).endsWith(`参考号:${status.lastMessageError.referenceId}`),
|
||
true,
|
||
command,
|
||
);
|
||
assert.doesNotMatch(sent.at(-1), /private .* provider detail/, command);
|
||
}
|
||
});
|
||
|
||
test('preset card selection does not expose internal update errors', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const updates = [];
|
||
const catalog = {
|
||
defaultId: 'preset-one',
|
||
items: [
|
||
{ id: 'preset-one', label: 'Preset One' },
|
||
{ id: 'preset-two', label: 'Preset Two' },
|
||
],
|
||
};
|
||
const harness = {
|
||
...sessionsHarness(1),
|
||
agentPresetSettings: async () => ({
|
||
agentPreset: 'preset-one',
|
||
agentPresetCatalog: { ...catalog, items: catalog.items.map((item) => ({ ...item })) },
|
||
}),
|
||
updateAgentPreset: async (presetId) => {
|
||
updates.push(presetId);
|
||
throw new Error('secret-shaped preset detail /private/path');
|
||
},
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('preset-card-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction({
|
||
operator: { open_id: 'ou_owner' },
|
||
action: { value: { action: 'preset_pick' }, option: 'preset-two' },
|
||
context: { open_message_id: 'om_card_1' },
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
const replies = sent
|
||
.filter((message) => message.msgType === 'text')
|
||
.map((message) => JSON.parse(message.content).text);
|
||
assert.deepEqual(updates, ['preset-two']);
|
||
assert.equal(replies.length, 1);
|
||
assert.match(replies[0], /失败,请稍后重试/);
|
||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path/);
|
||
});
|
||
|
||
function cards(messages) { return messages.filter((m) => m.msgType === 'interactive'); }
|
||
|
||
test('/sessions alias uses the interactive session list and paginates across 25 sessions', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ chatId, msgType, content }) => {
|
||
sent.push({ chatId, msgType, content });
|
||
}),
|
||
channel: {},
|
||
harness: sessionsHarness(25),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('sessions-open', ' /SESSIONS ', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards(sent).length, 1);
|
||
const page0 = cards(sent).at(-1).content;
|
||
const firstLayout = page0.body.elements.find((element) => element.tag === 'column_set');
|
||
const firstButton = firstLayout?.columns?.[0]?.elements?.[0];
|
||
assert.equal(firstButton?.tag, 'button');
|
||
assert.equal(Object.hasOwn(firstButton, 'value'), false, 'V2 buttons must not use the legacy value field');
|
||
assert.equal(callbackAction(firstButton), 'watch:session-01', 'the watch toggle leads each row');
|
||
const sessionButton = firstLayout?.columns?.[1]?.elements?.[0];
|
||
assert.equal(callbackAction(sessionButton), 'use:session-01');
|
||
assert.equal(useActionsFromCard(page0).length, 10);
|
||
assert.equal(useActionsFromCard(page0)[0], 'session-01');
|
||
|
||
// Button on page 0 asks for page 2 (zero-based page number).
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:2', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
const page2 = cards(sent).at(-1).content;
|
||
assert.equal(useActionsFromCard(page2).length, 5);
|
||
assert.equal(useActionsFromCard(page2)[0], 'session-21', 'page 2 must start at the 21st session (no double page scaling)');
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_2', 'sessions:1', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
const page1 = cards(sent).at(-1).content;
|
||
assert.equal(useActionsFromCard(page1).length, 10);
|
||
assert.equal(useActionsFromCard(page1)[0], 'session-11');
|
||
});
|
||
|
||
test('number replies on a later session page use page-local labels', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness: sessionsHarness(25),
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('sessions-number-open', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:2', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
const page2Buttons = buttonsFromCard(cards(sent).at(-1).content);
|
||
const sessionButtons = page2Buttons.filter((candidate) => (callbackAction(candidate) ?? '').startsWith('use:'));
|
||
assert.match(sessionButtons[0].text.content, /^1\. Session 21$/);
|
||
|
||
await bridge.accept(event('sessions-number-pick', '1', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
// #bindSession 先发文本确认,再原地更新/重发菜单卡;取最后一条文本消息验证绑定结果
|
||
const texts = sent.filter((m) => m.msgType !== 'interactive');
|
||
assert.match(JSON.parse(texts.at(-1).content).text, /ID:session-21/);
|
||
});
|
||
|
||
test('session pagination preserves an explicitly selected workspace', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const workspaceARaw = join(tmpdir(), `dsh-im-card-current-${process.pid}`);
|
||
const workspaceBRaw = join(tmpdir(), `dsh-im-card-selected-${process.pid}`);
|
||
mkdirSync(workspaceARaw, { recursive: true });
|
||
mkdirSync(workspaceBRaw, { recursive: true });
|
||
const workspaceA = realpathSync(workspaceARaw);
|
||
const workspaceB = realpathSync(workspaceBRaw);
|
||
const sessionSet = (prefix) => Array.from({ length: 25 }, (_, index) => ({
|
||
sessionId: `${prefix}-${String(index + 1).padStart(2, '0')}`,
|
||
title: `${prefix} Session ${index + 1}`,
|
||
}));
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness: {
|
||
ensureRunning: async () => true,
|
||
currentWorkspace: () => workspaceA,
|
||
listWorkspaces: async () => [workspaceA, workspaceB],
|
||
listWorkspaceSessions: async (workspace) => ({
|
||
workspace,
|
||
sessions: workspace === workspaceB ? sessionSet('selected') : sessionSet('current'),
|
||
}),
|
||
bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: sessionId }),
|
||
switchWorkspace: async (path) => path,
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('selected-workspace-open', '/sessions 2', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-01');
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:1', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-11');
|
||
const header = cards(sent).at(-1).content.body.elements[0].text.content;
|
||
assert.match(header, new RegExp(workspaceB.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||
});
|
||
|
||
const REPAIR_APP_ID = 'cli_repair_test';
|
||
const REPAIR_BOT_ID = 'bot_repair_test';
|
||
const REPAIR_URL = `https://open.feishu.cn/page/launcher?tp=sdk&clientID=${REPAIR_APP_ID}&addons=safe`;
|
||
|
||
function repairStatus(state = 'qr_ready', overrides = {}) {
|
||
return {
|
||
registration: {
|
||
operation: 'callback_repair',
|
||
state,
|
||
attempt: 'repair_attempt_1',
|
||
botId: REPAIR_BOT_ID,
|
||
qrCodeUrl: REPAIR_URL,
|
||
expiresAt: Date.now() + 60_000,
|
||
remainingSeconds: 60,
|
||
...overrides,
|
||
},
|
||
};
|
||
}
|
||
|
||
function repairCapability({
|
||
startStatus = repairStatus(),
|
||
status = startStatus,
|
||
cancelStatus = repairStatus('cancelled', { qrCodeUrl: undefined }),
|
||
} = {}) {
|
||
const calls = { start: [], status: [], cancel: [] };
|
||
return {
|
||
calls,
|
||
capability: {
|
||
async start(args) {
|
||
calls.start.push(args);
|
||
return typeof startStatus === 'function'
|
||
? startStatus(calls.start.length, args)
|
||
: startStatus;
|
||
},
|
||
async status(args) {
|
||
calls.status.push(args);
|
||
return typeof status === 'function' ? status(calls.status.length, args) : status;
|
||
},
|
||
async cancel(args) {
|
||
calls.cancel.push(args);
|
||
return typeof cancelStatus === 'function'
|
||
? cancelStatus(calls.cancel.length, args)
|
||
: cancelStatus;
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function repairBridge({
|
||
allowedSenderOpenIds = new Set(['ou_owner']),
|
||
capability,
|
||
client,
|
||
sent = [],
|
||
} = {}) {
|
||
const fixture = stateFixture();
|
||
let asks = 0;
|
||
const activeClient = client ?? cardClient(async (outgoing) => sent.push(outgoing));
|
||
return {
|
||
fixture,
|
||
sent,
|
||
get asks() { return asks; },
|
||
bridge: new FeishuHarnessBridge({
|
||
client: activeClient,
|
||
channel: {},
|
||
harness: {
|
||
ensureRunning: async () => true,
|
||
ask: async () => { asks += 1; return 'unexpected'; },
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds,
|
||
botId: REPAIR_BOT_ID,
|
||
appId: REPAIR_APP_ID,
|
||
repair: capability,
|
||
repairPollIntervalMs: 5,
|
||
repairLinkWaitMs: 100,
|
||
}),
|
||
};
|
||
}
|
||
|
||
test('/repair sends a validated ordinary SDK link without prompting Harness', async () => {
|
||
const repair = repairCapability();
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
|
||
await fx.bridge.accept(event('repair-start', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
|
||
assert.equal(repair.calls.start.length, 1);
|
||
assert.deepEqual(repair.calls.start[0], {
|
||
botId: REPAIR_BOT_ID,
|
||
actorOpenId: 'ou_owner',
|
||
chatId: 'oc_chat',
|
||
});
|
||
assert.equal(fx.asks, 0);
|
||
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||
assert.match(message, /card\.action\.trigger/);
|
||
assert.match(message, /im:message:readonly/);
|
||
assert.match(message, /im:resource/);
|
||
assert.match(message, new RegExp(REPAIR_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||
assert.match(message, /\/repair qr/);
|
||
});
|
||
|
||
test('repeating bare /repair replaces a still-waiting one-time link', async () => {
|
||
const oldUrl = `${REPAIR_URL}&user_code=old`;
|
||
const freshUrl = `${REPAIR_URL}&user_code=fresh`;
|
||
const repair = repairCapability({
|
||
startStatus: (count) => repairStatus('qr_ready', {
|
||
attempt: `repair_attempt_${count}`,
|
||
qrCodeUrl: count === 1 ? oldUrl : freshUrl,
|
||
}),
|
||
status: (_count, args) => repairStatus('qr_ready', {
|
||
attempt: args.attemptId,
|
||
qrCodeUrl: args.attemptId === 'repair_attempt_1' ? oldUrl : freshUrl,
|
||
}),
|
||
cancelStatus: (_count, args) => repairStatus('cancelled', {
|
||
attempt: args.attemptId,
|
||
qrCodeUrl: undefined,
|
||
}),
|
||
});
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
|
||
await fx.bridge.accept(event('repair-retry-first', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
await fx.bridge.accept(event('repair-retry-second', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
|
||
assert.equal(repair.calls.start.length, 2);
|
||
assert.equal(repair.calls.cancel.length, 1);
|
||
assert.equal(repair.calls.cancel[0].attemptId, 'repair_attempt_1');
|
||
const reply = JSON.parse(fx.sent.at(-1).content).text;
|
||
assert.match(reply, /旧授权链接已作废/);
|
||
assert.match(reply, /user_code=fresh/);
|
||
assert.doesNotMatch(reply, /user_code=old/);
|
||
assert.match(reply, /获取单聊、群组消息/);
|
||
assert.match(reply, /im:resource/);
|
||
assert.match(reply, /只会显示当前缺少的项/);
|
||
});
|
||
|
||
test('repeating bare /repair never duplicates an update already being saved', async () => {
|
||
const repair = repairCapability({
|
||
status: repairStatus('saving', { qrCodeUrl: undefined }),
|
||
});
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
|
||
await fx.bridge.accept(event('repair-saving-first', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
await fx.bridge.accept(event('repair-saving-second', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
|
||
assert.equal(repair.calls.start.length, 1);
|
||
assert.equal(repair.calls.cancel.length, 0);
|
||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /正在等待专用测试按钮/);
|
||
});
|
||
|
||
test('/repair status after a runtime restart never starts a duplicate authorization', async () => {
|
||
const repair = repairCapability();
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
|
||
await fx.bridge.accept(event('repair-restarted-status', '/repair status', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await fx.bridge.waitForIdle();
|
||
|
||
assert.equal(repair.calls.start.length, 0);
|
||
assert.equal(repair.calls.status.length, 0);
|
||
assert.equal(repair.calls.cancel.length, 0);
|
||
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||
assert.match(message, /没有可恢复的修复任务记录/);
|
||
assert.match(message, /不会启动新的授权/);
|
||
});
|
||
|
||
test('menu permission-completion entry is number-only and reply 5 starts the same repair flow', async () => {
|
||
const repair = repairCapability();
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
|
||
await fx.bridge.accept(event('repair-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
const menu = cards(fx.sent)[0].content;
|
||
assert.match(JSON.stringify(menu), /\*\*5\*\*🔧补全权限/);
|
||
assert.equal(buttonsFromCard(menu).some((button) => callbackAction(button) === 'repair'), false);
|
||
|
||
await fx.bridge.accept(event('repair-menu-five', '5', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
assert.equal(repair.calls.start.length, 1);
|
||
assert.equal(fx.asks, 0);
|
||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /card\.action\.trigger/);
|
||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /im:message:readonly/);
|
||
assert.match(JSON.parse(fx.sent.at(-1).content).text, /im:resource/);
|
||
});
|
||
|
||
test('chat repair follows the channel access policy without a separate administrator role', async () => {
|
||
const wildcardRepair = repairCapability();
|
||
const wildcard = repairBridge({
|
||
allowedSenderOpenIds: new Set(['*']),
|
||
capability: wildcardRepair.capability,
|
||
});
|
||
await wildcard.bridge.accept(event('repair-wildcard', '/repair', { senderOpenId: 'ou_anyone' }));
|
||
await wildcard.bridge.waitForIdle();
|
||
assert.equal(wildcardRepair.calls.start.length, 1);
|
||
assert.equal(wildcardRepair.calls.start[0].actorOpenId, 'ou_anyone');
|
||
|
||
const groupRepair = repairCapability();
|
||
const group = repairBridge({ capability: groupRepair.capability });
|
||
await group.bridge.accept(event('repair-group', '/repair', {
|
||
senderOpenId: 'ou_owner',
|
||
chat_type: 'group',
|
||
chat_id: 'oc_group',
|
||
}));
|
||
await group.bridge.waitForIdle();
|
||
assert.equal(groupRepair.calls.start.length, 0);
|
||
assert.match(JSON.parse(group.sent.at(-1).content).text, /请私聊机器人/);
|
||
});
|
||
|
||
test('/repair qr, status, verify and cancel stay scoped to the initiating user', async () => {
|
||
const sent = [];
|
||
let sequence = 0;
|
||
const client = {
|
||
im: { v1: {
|
||
image: { create: async ({ data }) => {
|
||
assert.equal(data.image_type, 'message');
|
||
assert.equal(Buffer.isBuffer(data.image), true);
|
||
return { image_key: 'img_repair_qr' };
|
||
} },
|
||
message: { create: async (request) => {
|
||
sent.push(request);
|
||
sequence += 1;
|
||
return { code: 0, data: { message_id: `om_repair_${sequence}` } };
|
||
} },
|
||
} },
|
||
};
|
||
const repair = repairCapability();
|
||
const fx = repairBridge({ capability: repair.capability, client, sent });
|
||
|
||
await fx.bridge.accept(event('repair-commands-start', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
await fx.bridge.accept(event('repair-commands-qr', '/repair qr', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
assert.equal(sent.some((request) => request.data.msg_type === 'image'
|
||
&& JSON.parse(request.data.content).image_key === 'img_repair_qr'), true);
|
||
|
||
await fx.bridge.accept(event('repair-commands-status', '/repair status', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.accept(event('repair-commands-verify', '/repair verify', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
assert.equal(repair.calls.start.length, 1);
|
||
assert.equal(repair.calls.cancel.length, 0);
|
||
const textMessages = sent
|
||
.filter((request) => request.data.msg_type === 'text')
|
||
.map((request) => JSON.parse(request.data.content).text);
|
||
assert.equal(textMessages.some((text) => text.includes('修复任务正在等待授权')), true);
|
||
assert.equal(textMessages.some((text) => text.includes('授权尚未完成')), true);
|
||
|
||
await fx.bridge.accept(event('repair-commands-cancel', '/repair cancel', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
assert.equal(repair.calls.cancel.length, 1);
|
||
assert.equal(repair.calls.cancel[0].actorOpenId, 'ou_owner');
|
||
});
|
||
|
||
test('/repair cancel only reports cancellation when the controller confirms it', async () => {
|
||
for (const state of ['saving', 'succeeded']) {
|
||
const repair = repairCapability({
|
||
cancelStatus: repairStatus(state, { qrCodeUrl: undefined }),
|
||
status: repairStatus(state, { qrCodeUrl: undefined }),
|
||
});
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
await fx.bridge.accept(event(`repair-cancel-${state}-start`, '/repair', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await fx.bridge.waitForIdle();
|
||
await fx.bridge.accept(event(`repair-cancel-${state}`, '/repair cancel', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await fx.bridge.waitForIdle();
|
||
|
||
const reply = JSON.parse(fx.sent.at(-1).content).text;
|
||
assert.doesNotMatch(reply, /已取消本次修复授权/);
|
||
assert.match(reply, state === 'saving' ? /正在等待专用测试按钮/ : /修复完成/);
|
||
await eventually(() => repair.calls.status.length > 0);
|
||
}
|
||
});
|
||
|
||
test('/repair rejects placeholder or mismatched launcher links and cancels the attempt', async () => {
|
||
for (const badUrl of [
|
||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D',
|
||
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_other_app',
|
||
`https://open.feishu.cn/page/launcher?tp=card&clientID=${REPAIR_APP_ID}`,
|
||
]) {
|
||
const repair = repairCapability({
|
||
startStatus: repairStatus('qr_ready', { qrCodeUrl: badUrl }),
|
||
});
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
await fx.bridge.accept(event(`repair-bad-${repair.calls.start.length}-${badUrl.length}`, '/repair', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await fx.bridge.waitForIdle();
|
||
assert.equal(repair.calls.cancel.length, 1);
|
||
const text = JSON.parse(fx.sent.at(-1).content).text;
|
||
assert.match(text, /无法安全验证/);
|
||
assert.doesNotMatch(text, /\{\{client_id\}\}|cli_other_app/);
|
||
}
|
||
});
|
||
|
||
test('repair monitor reports expiry without claiming that the callback was fixed', async () => {
|
||
const repair = repairCapability({
|
||
status: repairStatus('expired', {
|
||
qrCodeUrl: undefined,
|
||
remainingSeconds: 0,
|
||
error: { code: 'expired_token', message: 'safe' },
|
||
}),
|
||
});
|
||
const fx = repairBridge({ capability: repair.capability });
|
||
await fx.bridge.accept(event('repair-expiry', '/repair', { senderOpenId: 'ou_owner' }));
|
||
await fx.bridge.waitForIdle();
|
||
await eventually(() => fx.sent.some((outgoing) => (
|
||
outgoing.msgType === 'text'
|
||
&& JSON.parse(outgoing.content).text.includes('授权链接已过期')
|
||
)));
|
||
const terminal = fx.sent
|
||
.filter((outgoing) => outgoing.msgType === 'text')
|
||
.map((outgoing) => JSON.parse(outgoing.content).text)
|
||
.find((text) => text.includes('授权链接已过期'));
|
||
assert.doesNotMatch(terminal, /修复完成/);
|
||
});
|
||
|
||
// ── Watches: read-only tracking, persistence, compensation, dedup ─────────
|
||
|
||
import { StateStore } from '../../../src/channels/feishu/state-store.mjs';
|
||
|
||
function watchHarness({ sessionsByWorkspace = { 'C:/work': [] }, current = 'C:/work', history = [] } = {}) {
|
||
const listeners = [];
|
||
let currentHistory = history;
|
||
return {
|
||
ensureRunning: async () => true,
|
||
currentWorkspace: () => current,
|
||
listWorkspaces: async () => Object.keys(sessionsByWorkspace),
|
||
listWorkspaceSessions: async (workspace) => ({ workspace, sessions: sessionsByWorkspace[workspace] ?? [] }),
|
||
bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: `Title ${sessionId}` }),
|
||
switchWorkspace: async (path) => path,
|
||
rpc: async (method, params) => (method === 'session.history' ? { events: currentHistory } : null),
|
||
watchHarnessEvents: ({ signal, onSessionEvent, onReconnect }) => {
|
||
listeners.push({ signal, onSessionEvent, onReconnect });
|
||
return new Promise((resolve) => {
|
||
if (signal.aborted) resolve();
|
||
else signal.addEventListener('abort', resolve, { once: true });
|
||
});
|
||
},
|
||
_listeners: listeners,
|
||
_setHistory: (next) => { currentHistory = next; },
|
||
};
|
||
}
|
||
|
||
async function watchStoreFixture(seedSessions = []) {
|
||
const path = join(tmpdir(), `dsh-im-watch-test-${Math.random().toString(36).slice(2)}.json`);
|
||
const store = new StateStore(path);
|
||
await store.load();
|
||
for (const [key, sessionId] of seedSessions) await store.setSession(key, sessionId);
|
||
return { path, store, state: store };
|
||
}
|
||
|
||
test('/watch resolves read-only: no binding, no workspace switch', async () => {
|
||
const { state } = await watchStoreFixture([['p2p:ou_owner', 'bound-session']]);
|
||
let bindCalls = 0;
|
||
let switchCalls = 0;
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'target-session', title: 'Target' }] },
|
||
});
|
||
harness.bindWorkspaceSession = async () => { bindCalls += 1; throw new Error('must not bind'); };
|
||
harness.switchWorkspace = async () => { switchCalls += 1; throw new Error('must not switch'); };
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-1', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.match(sent.at(-1), /已关注会话「Target」/);
|
||
assert.equal(bindCalls, 0, 'watch must not bind the conversation');
|
||
assert.equal(switchCalls, 0, 'watch must not switch workspaces');
|
||
assert.equal(state.sessionFor('p2p:ou_owner'), 'bound-session', 'existing binding unchanged');
|
||
const entry = state.watchEntry('p2p:ou_owner', 'target-session');
|
||
assert.ok(entry, 'watch entry persisted');
|
||
assert.equal(entry.chatId, 'oc_chat');
|
||
});
|
||
|
||
test('/watch persists and replies before a slow history baseline finishes', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const history = deferred();
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'slow-session', title: 'Slow Session' }] },
|
||
});
|
||
harness.rpc = async (method) => (method === 'session.history' ? history.promise : null);
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
let settled = false;
|
||
const watching = bridge.accept(event('watch-slow-history', '/watch 1', {
|
||
senderOpenId: 'ou_owner',
|
||
})).then(() => { settled = true; });
|
||
await eventually(() => settled, '/watch waited for the slow session.history RPC');
|
||
|
||
assert.match(sent.at(-1), /已关注会话「Slow Session」/);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'slow-session').lastSeq, null);
|
||
|
||
let idleSettled = false;
|
||
const idle = bridge.waitForIdle().then(() => { idleSettled = true; });
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(idleSettled, false, 'waitForIdle must include compensation added by /watch');
|
||
|
||
history.resolve({ events: [] });
|
||
await watching;
|
||
await idle;
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'slow-session').lastSeq, -1);
|
||
});
|
||
|
||
test('watch baseline delivers a completion that arrives while history is pending exactly once', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const history = deferred();
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'race-session', title: 'Race Session' }] },
|
||
});
|
||
harness.rpc = async (method) => (method === 'session.history' ? history.promise : null);
|
||
const completionCards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') completionCards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-history-race', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
const pendingEntry = state.watchEntry('p2p:ou_owner', 'race-session');
|
||
assert.equal(pendingEntry.lastSeq, null);
|
||
assert.ok(Number.isSafeInteger(pendingEntry.watchStartedAt));
|
||
await eventually(() => harness._listeners.length === 1);
|
||
|
||
const oldCompletion = {
|
||
event: {
|
||
type: 'turn/end', seq: 10, time: pendingEntry.watchStartedAt - 1,
|
||
data: { turn: 'old', reason: { kind: 'completed' } },
|
||
},
|
||
};
|
||
const newCompletion = {
|
||
event: {
|
||
type: 'turn/end', seq: 11, time: pendingEntry.watchStartedAt,
|
||
data: { turn: 'new', reason: { kind: 'completed' } },
|
||
},
|
||
};
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'race-session',
|
||
event: newCompletion.event,
|
||
});
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(completionCards.length, 0, 'live delivery must wait for the pending baseline');
|
||
assert.ok(
|
||
Number.isSafeInteger(state.watchEntry('p2p:ou_owner', 'race-session').watchStartedAt),
|
||
'live delivery must not clear the pending boundary',
|
||
);
|
||
history.resolve({ events: [oldCompletion, newCompletion] });
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(completionCards.length, 1);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'race-session').lastSeq, 11);
|
||
assert.equal('watchStartedAt' in state.watchEntry('p2p:ou_owner', 'race-session'), false);
|
||
});
|
||
|
||
test('watch baseline does not swallow a live completion when mixed history omits its time', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const history = deferred();
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'mixed-time-session', title: 'Mixed Time' }] },
|
||
});
|
||
harness.rpc = async (method) => (method === 'session.history' ? history.promise : null);
|
||
const completionCards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') completionCards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-mixed-time-race', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
const pendingEntry = state.watchEntry('p2p:ou_owner', 'mixed-time-session');
|
||
await eventually(() => harness._listeners.length === 1);
|
||
const liveCompletion = {
|
||
type: 'turn/end',
|
||
seq: 11,
|
||
data: { turn: 'new-without-time', reason: { kind: 'completed' } },
|
||
};
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'mixed-time-session',
|
||
event: liveCompletion,
|
||
});
|
||
history.resolve({
|
||
events: [
|
||
{
|
||
event: {
|
||
type: 'turn/end', seq: 10, time: pendingEntry.watchStartedAt - 1,
|
||
data: { turn: 'old', reason: { kind: 'completed' } },
|
||
},
|
||
},
|
||
{ event: liveCompletion },
|
||
],
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(completionCards.length, 1);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'mixed-time-session').lastSeq, 11);
|
||
});
|
||
|
||
test('a no-time completion during target listing is retained before the watch exists', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const listStarted = deferred();
|
||
const listRelease = deferred();
|
||
const harness = watchHarness();
|
||
harness.listWorkspaceSessions = async () => {
|
||
listStarted.resolve();
|
||
return listRelease.promise;
|
||
};
|
||
const completionCards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') completionCards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
const watching = bridge.accept(event('watch-list-window', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
await listStarted.promise;
|
||
await eventually(() => harness._listeners.length === 1);
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'list-window-session',
|
||
event: {
|
||
type: 'turn/end', seq: 11,
|
||
data: { turn: 'new-without-time', reason: { kind: 'completed' } },
|
||
},
|
||
});
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'list-window-session'), null);
|
||
assert.equal(completionCards.length, 0);
|
||
|
||
listRelease.resolve({
|
||
workspace: 'C:/work',
|
||
sessions: [{ sessionId: 'list-window-session', title: 'List Window', lastSeq: 10 }],
|
||
});
|
||
await watching;
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(completionCards.length, 1, 'the pre-watch live payload must be compensated exactly once');
|
||
const settled = state.watchEntry('p2p:ou_owner', 'list-window-session');
|
||
assert.equal(settled.lastSeq, 11);
|
||
assert.equal('watchStartedAt' in settled, false);
|
||
});
|
||
|
||
test('a no-time completion during setWatch persistence is retained before state mutation', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const setWatchStarted = deferred();
|
||
const setWatchRelease = deferred();
|
||
const originalSetWatch = state.setWatch.bind(state);
|
||
let holdInitialWatch = true;
|
||
state.setWatch = async (key, entry) => {
|
||
if (holdInitialWatch && entry.sessionId === 'persist-window-session') {
|
||
holdInitialWatch = false;
|
||
setWatchStarted.resolve();
|
||
await setWatchRelease.promise;
|
||
}
|
||
return originalSetWatch(key, entry);
|
||
};
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: {
|
||
'C:/work': [{ sessionId: 'persist-window-session', title: 'Persist Window', lastSeq: 10 }],
|
||
},
|
||
});
|
||
const completionCards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') completionCards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
const watching = bridge.accept(event('watch-persist-window', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
await setWatchStarted.promise;
|
||
await eventually(() => harness._listeners.length === 1);
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'persist-window-session',
|
||
event: {
|
||
type: 'turn/end', seq: 11,
|
||
data: { turn: 'new-without-time', reason: { kind: 'completed' } },
|
||
},
|
||
});
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'persist-window-session'), null);
|
||
assert.equal(completionCards.length, 0);
|
||
|
||
setWatchRelease.resolve();
|
||
await watching;
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(completionCards.length, 1, 'the pre-persistence live payload must be delivered once');
|
||
const settled = state.watchEntry('p2p:ou_owner', 'persist-window-session');
|
||
assert.equal(settled.lastSeq, 11);
|
||
assert.equal('watchStartedAt' in settled, false);
|
||
});
|
||
|
||
test('a cold session-list watermark is only a lower bound and does not replay old history', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const history = deferred();
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: {
|
||
'C:/work': [{ sessionId: 'cold-watermark-session', title: 'Cold', lastSeq: 5 }],
|
||
},
|
||
});
|
||
harness.rpc = async (method) => (method === 'session.history' ? history.promise : null);
|
||
const completionCards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') completionCards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-cold-watermark', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
const pending = state.watchEntry('p2p:ou_owner', 'cold-watermark-session');
|
||
assert.equal(pending.lastSeq, 5);
|
||
assert.ok(Number.isSafeInteger(pending.watchStartedAt));
|
||
history.resolve({
|
||
events: [{
|
||
event: {
|
||
type: 'turn/end', seq: 10, time: pending.watchStartedAt - 1,
|
||
data: { turn: 'old', reason: { kind: 'completed' } },
|
||
},
|
||
}],
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(completionCards.length, 0, 'history newer than stale asOfSeq can still predate /watch');
|
||
const settled = state.watchEntry('p2p:ou_owner', 'cold-watermark-session');
|
||
assert.equal(settled.lastSeq, 10);
|
||
assert.equal('watchStartedAt' in settled, false);
|
||
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'cold-watermark-session',
|
||
event: {
|
||
type: 'turn/end', seq: 11, time: pending.watchStartedAt,
|
||
data: { turn: 'new', reason: { kind: 'completed' } },
|
||
},
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(completionCards.length, 1);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'cold-watermark-session').lastSeq, 11);
|
||
});
|
||
|
||
test('watch multi-select accepts CSV and legacy top-level callback fields', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const sessions = [
|
||
{ sessionId: 'session-a', title: 'Session A', lastSeq: -1 },
|
||
{ sessionId: 'session-b', title: 'Session B', lastSeq: 4 },
|
||
];
|
||
const harness = watchHarness({ sessionsByWorkspace: { 'C:/work': sessions } });
|
||
const sent = [];
|
||
const patches = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async (outgoing) => sent.push(outgoing),
|
||
async (request) => patches.push(request),
|
||
),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-list-csv', '/watchlist', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction({
|
||
open_id: 'ou_owner',
|
||
open_message_id: 'om_card_1',
|
||
open_chat_id: 'oc_chat',
|
||
action: { value: JSON.stringify({ action: 'watch_add', kind: 'multi' }), options: '' },
|
||
});
|
||
assert.deepEqual(state.watchEntries('p2p:ou_owner'), []);
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /请先选择至少一个会话/);
|
||
|
||
await bridge.onCardAction({
|
||
open_id: 'ou_owner',
|
||
open_message_id: 'om_card_1',
|
||
open_chat_id: 'oc_chat',
|
||
action: {
|
||
value: JSON.stringify({ action: 'watch_add', kind: 'multi' }),
|
||
options: 'session-a,session-b',
|
||
},
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(
|
||
state.watchEntries('p2p:ou_owner').map((entry) => entry.sessionId).sort(),
|
||
['session-a', 'session-b'],
|
||
);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'session-a').lastSeq, -1);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'session-b').lastSeq, 4);
|
||
assert.equal(patches.at(-1).path.message_id, 'om_card_1');
|
||
const addReplies = sent.filter((message) => message.msgType === 'text');
|
||
assert.equal(addReplies.length, 2, 'batch add must send one summary, not one reply per session');
|
||
assert.match(JSON.parse(addReplies[1].content).text, /已批量关注 2 个会话/);
|
||
|
||
await bridge.onCardAction({
|
||
open_id: 'ou_owner',
|
||
open_message_id: 'om_card_1',
|
||
open_chat_id: 'oc_chat',
|
||
action: {
|
||
value: JSON.stringify({ action: 'watch_remove', kind: 'multi' }),
|
||
options: JSON.stringify([{ value: 'session-a' }, { value: 'session-b' }]),
|
||
},
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(state.watchEntries('p2p:ou_owner'), []);
|
||
const allReplies = sent.filter((message) => message.msgType === 'text');
|
||
assert.equal(allReplies.length, 3, 'batch remove must also send exactly one summary');
|
||
assert.match(JSON.parse(allReplies[2].content).text, /已取消关注 2 个会话/);
|
||
});
|
||
|
||
test('session-row watch toggles fresh-validate the session and patch the same page', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const work = realpathSync(tmpdir());
|
||
const sessions = [{ sessionId: 'session-row', title: 'Session Row', lastSeq: 7 }];
|
||
const harness = watchHarness({ current: work, sessionsByWorkspace: { [work]: sessions } });
|
||
let listCalls = 0;
|
||
const listWorkspaceSessions = harness.listWorkspaceSessions;
|
||
harness.listWorkspaceSessions = async (...args) => {
|
||
listCalls += 1;
|
||
return listWorkspaceSessions(...args);
|
||
};
|
||
const sent = [];
|
||
const patches = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async (outgoing) => sent.push(outgoing),
|
||
async (request) => patches.push(request),
|
||
),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('session-row-open', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(listCalls, 1);
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'watch:session-row', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'session-row').lastSeq, 7);
|
||
assert.equal(patches.at(-1).path.message_id, 'om_card_1');
|
||
assert.equal(
|
||
buttonsFromCard(JSON.parse(patches.at(-1).data.content)).some((button) => (
|
||
callbackAction(button) === 'unwatch:session-row'
|
||
)),
|
||
true,
|
||
);
|
||
assert.equal(listCalls, 3, 'watch click must fresh-validate once before refreshing the page');
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'unwatch:session-row', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'session-row'), null);
|
||
assert.equal(patches.at(-1).path.message_id, 'om_card_1');
|
||
assert.equal(
|
||
buttonsFromCard(JSON.parse(patches.at(-1).data.content)).some((button) => (
|
||
callbackAction(button) === 'watch:session-row'
|
||
)),
|
||
true,
|
||
);
|
||
});
|
||
|
||
test('a stale session card cannot create a watch after the session is deleted', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const work = realpathSync(tmpdir());
|
||
const sessions = [{ sessionId: 'deleted-session', title: 'Deleted', lastSeq: 2 }];
|
||
const harness = watchHarness({ current: work, sessionsByWorkspace: { [work]: sessions } });
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing), async () => ({ code: 0 })),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('deleted-session-card-open', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
sessions.splice(0, sessions.length);
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'watch:deleted-session', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'deleted-session'), null);
|
||
const replies = sent
|
||
.filter((message) => message.msgType === 'text')
|
||
.map((message) => JSON.parse(message.content).text);
|
||
assert.equal(replies.some((text) => text.includes('没有找到这个会话')), true);
|
||
});
|
||
|
||
test('watch persistence remains successful when its confirmation cannot be sent', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: {
|
||
'C:/work': [{ sessionId: 'send-failure-session', title: 'Still Watched', lastSeq: -1 }],
|
||
},
|
||
});
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => { throw new Error('Feishu unavailable'); }),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(event('watch-send-failure', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'send-failure-session').lastSeq, -1);
|
||
});
|
||
|
||
test('/watch finds a session in another workspace without switching', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const harness = watchHarness({
|
||
current: 'C:/work',
|
||
sessionsByWorkspace: {
|
||
'C:/work': [],
|
||
'D:/other': [{ sessionId: 'other-session', title: 'Other Session' }],
|
||
},
|
||
});
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async ({ text }) => sent.push(text)),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-x', '/watch other-session', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.match(sent.at(-1), /已关注会话「Other Session」/);
|
||
assert.equal(state.sessionFor('p2p:ou_owner'), null, 'cross-workspace watch must not bind');
|
||
assert.ok(state.watchEntry('p2p:ou_owner', 'other-session'));
|
||
});
|
||
|
||
test('persisted watches resume the event watcher at runtime start', async () => {
|
||
const { path, state } = await watchStoreFixture();
|
||
await state.setWatch('p2p:ou_owner', { sessionId: 'kept-session', title: 'Kept', chatId: 'oc_chat', lastSeq: 3 });
|
||
const reloadedState = await new StateStore(path).load();
|
||
const harness = watchHarness();
|
||
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness,
|
||
state: reloadedState,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
assert.equal(harness._listeners.length, 1, 'watcher must restart from persisted state');
|
||
assert.ok(bridge);
|
||
});
|
||
|
||
test('reconnect compensation replays missed turn/end and dedups duplicates', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
await state.setWatch('p2p:ou_owner', { sessionId: 'watched-session', title: 'Watched', chatId: 'oc_chat', lastSeq: 9 });
|
||
const harness = watchHarness({
|
||
history: [
|
||
{ event: { type: 'turn/end', seq: 11, data: { turn: 't2', reason: { kind: 'stopped' } } } },
|
||
{ event: { type: 'turn/end', seq: 10, data: { turn: 't1', reason: { kind: 'completed' } } } },
|
||
],
|
||
});
|
||
const cards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') cards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
assert.equal(harness._listeners.length, 1);
|
||
|
||
// Real history wraps events and may return them out of order.
|
||
harness._listeners[0].onReconnect();
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards.length, 2);
|
||
assert.match(JSON.stringify(cards[0]), /已完成/);
|
||
assert.match(JSON.stringify(cards[1]), /已停止/);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'watched-session').lastSeq, 11);
|
||
|
||
// Reconnect and an overlapping live frame are both deduplicated by lastSeq.
|
||
harness._listeners[0].onReconnect();
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'watched-session',
|
||
event: { type: 'turn/end', seq: 11, data: { turn: 't2', reason: { kind: 'stopped' } } },
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards.length, 2);
|
||
});
|
||
|
||
test('slow compensation for one session does not block another session completion', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
await state.setWatch('p2p:ou_a', {
|
||
sessionId: 'slow-session-a', title: 'Slow A', chatId: 'oc_a', lastSeq: 0,
|
||
});
|
||
await state.setWatch('p2p:ou_b', {
|
||
sessionId: 'fast-session-b', title: 'Fast B', chatId: 'oc_b', lastSeq: 0,
|
||
});
|
||
const slowHistory = deferred();
|
||
const harness = watchHarness();
|
||
let slowStarted = false;
|
||
harness.rpc = async (method, { sessionId }) => {
|
||
if (method !== 'session.history') return null;
|
||
if (sessionId === 'slow-session-a') {
|
||
slowStarted = true;
|
||
return slowHistory.promise;
|
||
}
|
||
return { events: [] };
|
||
};
|
||
const completionChats = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ chatId, msgType }) => {
|
||
if (msgType === 'interactive') completionChats.push(chatId);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await eventually(() => harness._listeners.length === 1);
|
||
|
||
harness._listeners[0].onReconnect();
|
||
await eventually(() => slowStarted);
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'fast-session-b',
|
||
event: {
|
||
type: 'turn/end', seq: 1, time: Date.now(),
|
||
data: { turn: 'fast', reason: { kind: 'completed' } },
|
||
},
|
||
});
|
||
await eventually(() => completionChats.includes('oc_b'), 'fast session was blocked by slow compensation');
|
||
assert.equal(state.watchEntry('p2p:ou_b', 'fast-session-b').lastSeq, 1);
|
||
|
||
slowHistory.resolve({ events: [] });
|
||
await bridge.waitForIdle();
|
||
});
|
||
|
||
test('a watcher added after a compensation snapshot receives a trailing compensation', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const sessionId = 'shared-compensation-session';
|
||
const firstKey = 'p2p:ou_first';
|
||
const secondKey = 'p2p:ou_second';
|
||
await state.setWatch(firstKey, {
|
||
sessionId,
|
||
title: 'First Watcher',
|
||
chatId: 'oc_first',
|
||
lastSeq: null,
|
||
watchStartedAt: Date.now() - 1_000,
|
||
});
|
||
|
||
const firstBaselineStarted = deferred();
|
||
const releaseFirstBaseline = deferred();
|
||
const originalSetWatch = state.setWatch.bind(state);
|
||
let heldFirstBaseline = false;
|
||
state.setWatch = async (key, entry) => {
|
||
if (!heldFirstBaseline
|
||
&& key === firstKey
|
||
&& entry.sessionId === sessionId
|
||
&& entry.lastSeq === 4) {
|
||
heldFirstBaseline = true;
|
||
firstBaselineStarted.resolve();
|
||
await releaseFirstBaseline.promise;
|
||
}
|
||
return originalSetWatch(key, entry);
|
||
};
|
||
|
||
const originalKeysWatching = state.keysWatching.bind(state);
|
||
let firstSnapshot = null;
|
||
let historyCalls = 0;
|
||
state.keysWatching = (candidateSessionId) => {
|
||
const keys = originalKeysWatching(candidateSessionId);
|
||
if (candidateSessionId === sessionId && firstSnapshot === null && historyCalls === 1) {
|
||
firstSnapshot = [...keys];
|
||
}
|
||
return keys;
|
||
};
|
||
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: {
|
||
'C:/work': [{ sessionId, title: 'Shared Session' }],
|
||
},
|
||
});
|
||
harness.rpc = async (method) => {
|
||
if (method !== 'session.history') return null;
|
||
historyCalls += 1;
|
||
return {
|
||
// A non-completion event establishes the sequence baseline without
|
||
// dynamically delivering to a watcher absent from the first keys snapshot.
|
||
events: [{
|
||
event: {
|
||
type: 'turn/start', seq: 4, time: 0,
|
||
data: { turn: 'old' },
|
||
},
|
||
}],
|
||
};
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_first', 'ou_second']),
|
||
});
|
||
await eventually(() => harness._listeners.length === 1);
|
||
|
||
harness._listeners[0].onReconnect();
|
||
await firstBaselineStarted.promise;
|
||
assert.deepEqual(firstSnapshot, [firstKey], 'the first compensation must already have its watcher snapshot');
|
||
|
||
await bridge.accept(event('watch-after-snapshot', '/watch 1', {
|
||
senderOpenId: 'ou_second',
|
||
chat_id: 'oc_second',
|
||
}));
|
||
const pendingSecond = state.watchEntry(secondKey, sessionId);
|
||
assert.equal(pendingSecond.lastSeq, null);
|
||
assert.ok(Number.isSafeInteger(pendingSecond.watchStartedAt));
|
||
|
||
releaseFirstBaseline.resolve();
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(historyCalls, 2, 'the new watcher must dirty the in-flight compensation and queue one tail pass');
|
||
const settledSecond = state.watchEntry(secondKey, sessionId);
|
||
assert.equal(settledSecond.lastSeq, 4);
|
||
assert.equal('watchStartedAt' in settledSecond, false);
|
||
});
|
||
|
||
test('/watch baselines existing history and completion-card buttons keep their route', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const work = realpathSync(tmpdir());
|
||
const oldCompletion = {
|
||
event: { type: 'turn/end', seq: 10, data: { turn: 'old', reason: { kind: 'completed' } } },
|
||
};
|
||
const harness = watchHarness({
|
||
current: work,
|
||
sessionsByWorkspace: { [work]: [{ sessionId: 'watched-session', title: 'Watched' }] },
|
||
history: [oldCompletion],
|
||
});
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-baseline', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'watched-session').lastSeq, 10);
|
||
assert.equal(cards(sent).length, 0, 'a new watch must not replay an older completion');
|
||
|
||
harness._listeners[0].onReconnect();
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards(sent).length, 0);
|
||
|
||
harness._setHistory([
|
||
oldCompletion,
|
||
{ event: { type: 'turn/end', seq: 11, data: { turn: 'new', reason: { kind: 'completed' } } } },
|
||
]);
|
||
harness._listeners[0].onReconnect();
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards(sent).length, 1);
|
||
|
||
// The text confirmation is om_card_1, so the completion is om_card_2.
|
||
await bridge.onCardAction(cardActionEvent('om_card_2', 'sessions', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(cards(sent).length, 2);
|
||
assert.equal(cards(sent).at(-1).content.header.title.content, '📂 会话列表');
|
||
});
|
||
|
||
test('a failed completion push keeps its watermark and later activity retries it', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
await state.setWatch('p2p:ou_owner', {
|
||
sessionId: 'cross-workspace-session',
|
||
title: 'Cross Workspace Title',
|
||
chatId: 'oc_chat',
|
||
lastSeq: 10,
|
||
});
|
||
const completion = {
|
||
event: { type: 'turn/end', seq: 11, data: { turn: 'retry', reason: { kind: 'completed' } } },
|
||
};
|
||
const laterCompletion = {
|
||
event: { type: 'turn/end', seq: 12, data: { turn: 'later', reason: { kind: 'completed' } } },
|
||
};
|
||
const harness = watchHarness({ history: [laterCompletion, completion] });
|
||
const cardsSent = [];
|
||
let failNext = true;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType !== 'interactive') return;
|
||
if (failNext) {
|
||
failNext = false;
|
||
throw new Error('temporary Feishu failure');
|
||
}
|
||
cardsSent.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
logger: { warn: () => undefined },
|
||
});
|
||
await eventually(() => harness._listeners.length === 1);
|
||
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'cross-workspace-session',
|
||
event: completion.event,
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'cross-workspace-session').lastSeq, 10);
|
||
assert.equal(cardsSent.length, 0);
|
||
|
||
// A later live completion recovers the earlier failure through history;
|
||
// no socket reconnect is required to unstick this watch.
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'cross-workspace-session',
|
||
event: laterCompletion.event,
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'cross-workspace-session').lastSeq, 12);
|
||
assert.equal(cardsSent.length, 2);
|
||
assert.match(JSON.stringify(cardsSent[0]), /Cross Workspace Title/);
|
||
});
|
||
|
||
test('legacy watches establish a baseline without replaying old completions', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
await state.setWatch('p2p:ou_owner', {
|
||
sessionId: 'legacy-session',
|
||
title: 'Legacy',
|
||
chatId: 'oc_chat',
|
||
lastSeq: null,
|
||
});
|
||
const harness = watchHarness({
|
||
history: [{ event: { type: 'turn/end', seq: 20, data: { turn: 'old' } } }],
|
||
});
|
||
const cardsSent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') cardsSent.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
await eventually(() => harness._listeners.length === 1);
|
||
|
||
harness._listeners[0].onReconnect();
|
||
await bridge.waitForIdle();
|
||
assert.equal(cardsSent.length, 0);
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'legacy-session').lastSeq, 20);
|
||
});
|
||
|
||
test('runtime abort stops the old event watcher before a new bridge starts', async () => {
|
||
const firstHarness = watchHarness();
|
||
const firstController = new AbortController();
|
||
const { state: firstState } = await watchStoreFixture();
|
||
new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness: firstHarness,
|
||
state: firstState,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
signal: firstController.signal,
|
||
});
|
||
await eventually(() => firstHarness._listeners.length === 1);
|
||
assert.equal(firstHarness._listeners[0].signal.aborted, false);
|
||
firstController.abort();
|
||
assert.equal(firstHarness._listeners[0].signal.aborted, true);
|
||
|
||
const secondHarness = watchHarness();
|
||
const secondController = new AbortController();
|
||
const { state: secondState } = await watchStoreFixture();
|
||
new FeishuHarnessBridge({
|
||
client: textClient(async () => {}),
|
||
channel: {},
|
||
harness: secondHarness,
|
||
state: secondState,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
signal: secondController.signal,
|
||
});
|
||
await eventually(() => secondHarness._listeners.length === 1);
|
||
assert.equal(secondHarness._listeners[0].signal.aborted, false);
|
||
secondController.abort();
|
||
});
|
||
|
||
test('archived sessions are hidden by default; /archived on reveals them', async () => {
|
||
const { state } = await watchStoreFixture();
|
||
const workRaw = join(tmpdir(), 'dsh-im-archived-test-work');
|
||
mkdirSync(workRaw, { recursive: true });
|
||
const work = realpathSync(workRaw);
|
||
const harness = watchHarness({
|
||
current: work,
|
||
sessionsByWorkspace: {
|
||
[work]: [
|
||
{ sessionId: 'live-session', title: 'Live', archived: false },
|
||
{ sessionId: 'old-session', title: 'Old', archived: true },
|
||
],
|
||
},
|
||
});
|
||
const sent = [];
|
||
const cards = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async ({ msgType, content }) => {
|
||
if (msgType === 'interactive') cards.push(content);
|
||
}),
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
bridge._sent = sent;
|
||
|
||
// Default: hidden. The explicit toggle re-enables inclusion for the card check.
|
||
assert.equal(state.includesArchivedSessions(), false);
|
||
|
||
await bridge.accept(event('sessions-arch', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const use = useActionsFromCard(cards.at(-1));
|
||
assert.deepEqual(use, ['live-session'], 'archived session hidden');
|
||
|
||
// The numeric watch index must resolve against the filtered list too.
|
||
await bridge.accept(event('watch-arch', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.ok(state.watchEntry('p2p:ou_owner', 'live-session'));
|
||
assert.equal(state.watchEntry('p2p:ou_owner', 'old-session'), null);
|
||
|
||
await bridge.accept(event('archived-on', '/archived on', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.accept(event('sessions-arch-2', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const useOn = useActionsFromCard(cards.at(-1));
|
||
assert.deepEqual(useOn, ['live-session', 'old-session'], '/archived on restores archived sessions');
|
||
});
|
||
|
||
// ── stop / steer while a task is running (menu card interactions) ──────────
|
||
|
||
function activeTurnHarness({ stopped = true, steered = true } = {}) {
|
||
const calls = { stop: [], steer: [], sessions: [] };
|
||
const harness = {
|
||
ensureRunning: async () => true,
|
||
workspaceSession: (id) => {
|
||
calls.sessions.push(id);
|
||
return {
|
||
async sessionExists() {
|
||
return true;
|
||
},
|
||
async ask(text, options) {
|
||
return harness.ask(id, text, options);
|
||
},
|
||
async stopActiveTurn(control, options) {
|
||
calls.stop.push({ control, options });
|
||
return stopped;
|
||
},
|
||
async steerActiveTurn(text, control, options) {
|
||
calls.steer.push({ text, control, options });
|
||
return steered;
|
||
},
|
||
};
|
||
},
|
||
};
|
||
return {
|
||
calls,
|
||
harness,
|
||
};
|
||
}
|
||
|
||
function steerDropdownEvent(messageId, option, operatorOpenId) {
|
||
return {
|
||
operator: { open_id: operatorOpenId },
|
||
action: { value: { action: 'steer_pick' }, option },
|
||
context: { open_message_id: messageId },
|
||
};
|
||
}
|
||
|
||
test('a full regular card queue cannot block stop and emits one overload notice', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const slowStatus = deferred();
|
||
const statusStarted = deferred();
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async (outgoing) => sent.push(outgoing),
|
||
async () => ({ code: 0 }),
|
||
),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-control-lane-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
harness.ensureRunning = async () => {
|
||
statusStarted.resolve();
|
||
return slowStatus.promise;
|
||
};
|
||
|
||
const regular = [bridge.onCardAction(cardActionEvent('om_card_1', 'status', 'ou_owner'))];
|
||
await statusStarted.promise;
|
||
for (let index = 0; index < 7; index += 1) {
|
||
regular.push(bridge.onCardAction(cardActionEvent(
|
||
'om_card_1',
|
||
`queued-regular-${index}`,
|
||
'ou_owner',
|
||
)));
|
||
}
|
||
const overflow = [];
|
||
for (let index = 0; index < 25; index += 1) {
|
||
overflow.push(bridge.onCardAction(cardActionEvent(
|
||
'om_card_1',
|
||
`overflow-${index}`,
|
||
'ou_owner',
|
||
)));
|
||
}
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
await Promise.all(overflow);
|
||
assert.equal(calls.stop.length, 1, 'stop must use the independent control lane');
|
||
const overloadNotices = sent
|
||
.filter((message) => message.msgType === 'text')
|
||
.map((message) => JSON.parse(message.content).text)
|
||
.filter((text) => text.includes('操作过于频繁'));
|
||
assert.equal(overloadNotices.length, 1, 'callback flood must be collapsed to one notice');
|
||
|
||
slowStatus.resolve(true);
|
||
await Promise.all(regular);
|
||
await bridge.waitForIdle();
|
||
});
|
||
|
||
test('custom steer stays in the regular lane and cannot occupy the real control lane', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const statusStarted = deferred();
|
||
const statusRelease = deferred();
|
||
const customPatchRelease = deferred();
|
||
const { calls, harness } = activeTurnHarness();
|
||
let customPatchStarted = false;
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(
|
||
async () => {},
|
||
async (request) => {
|
||
if (request.data.content.includes('输入补充指令')) {
|
||
customPatchStarted = true;
|
||
await customPatchRelease.promise;
|
||
}
|
||
return { code: 0 };
|
||
},
|
||
),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-custom-lane-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
harness.ensureRunning = async () => {
|
||
statusStarted.resolve();
|
||
return statusRelease.promise;
|
||
};
|
||
|
||
const status = bridge.onCardAction(cardActionEvent('om_card_1', 'status', 'ou_owner'));
|
||
await statusStarted.promise;
|
||
const custom = bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
|
||
const steering = bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||
const stopping = bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
let controlsSettled = false;
|
||
const controls = Promise.all([steering, stopping]).then(() => {
|
||
controlsSettled = true;
|
||
});
|
||
|
||
try {
|
||
await eventually(
|
||
() => controlsSettled,
|
||
'opening the custom steer form occupied the real steer/stop control lane',
|
||
);
|
||
assert.equal(customPatchStarted, false, 'custom steer must remain queued behind the regular status action');
|
||
assert.equal(calls.steer.length, 1);
|
||
assert.equal(calls.stop.length, 1);
|
||
} finally {
|
||
statusRelease.resolve(true);
|
||
customPatchRelease.resolve();
|
||
await Promise.allSettled([status, custom, controls]);
|
||
await bridge.waitForIdle();
|
||
}
|
||
});
|
||
|
||
test('one hundred provider-distinct stop callbacks coalesce while a stop is pending', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const stopStarted = deferred();
|
||
const stopRelease = deferred();
|
||
const { calls, harness } = activeTurnHarness();
|
||
const originalWorkspaceSession = harness.workspaceSession;
|
||
harness.workspaceSession = (id) => {
|
||
const session = originalWorkspaceSession(id);
|
||
session.stopActiveTurn = async (control, options) => {
|
||
calls.stop.push({ control, options });
|
||
stopStarted.resolve();
|
||
await stopRelease.promise;
|
||
return true;
|
||
};
|
||
return session;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async () => {}),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-stop-coalesce-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const callbacks = Array.from({ length: 100 }, (_, index) => bridge.onCardAction({
|
||
...cardActionEvent('om_card_1', 'stop', 'ou_owner'),
|
||
event_id: `evt_stop_flood_${index}`,
|
||
}));
|
||
await stopStarted.promise;
|
||
assert.equal(calls.stop.length, 1, 'only the leading stop may execute while it is unresolved');
|
||
|
||
stopRelease.resolve();
|
||
await Promise.all(callbacks);
|
||
await bridge.waitForIdle();
|
||
assert.equal(calls.stop.length, 1, 'distinct provider event ids must not expand one stop into queued work');
|
||
|
||
await bridge.onCardAction({
|
||
...cardActionEvent('om_card_1', 'stop', 'ou_owner'),
|
||
event_id: 'evt_stop_flood_99',
|
||
});
|
||
await bridge.waitForIdle();
|
||
assert.equal(calls.stop.length, 1, 'a coalesced follower retry must stay deduped after settlement');
|
||
});
|
||
|
||
test('actual steer and stop submissions stay ordered inside the control lane', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const steerRelease = deferred();
|
||
const steerStarted = deferred();
|
||
const order = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const originalWorkspaceSession = harness.workspaceSession;
|
||
harness.workspaceSession = (id) => {
|
||
const session = originalWorkspaceSession(id);
|
||
session.steerActiveTurn = async (text, control, options) => {
|
||
calls.steer.push({ text, control, options });
|
||
order.push('steer:start');
|
||
steerStarted.resolve();
|
||
await steerRelease.promise;
|
||
order.push('steer:end');
|
||
return true;
|
||
};
|
||
session.stopActiveTurn = async (control, options) => {
|
||
calls.stop.push({ control, options });
|
||
order.push('stop');
|
||
return true;
|
||
};
|
||
return session;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-control-order-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const steering = bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||
await steerStarted.promise;
|
||
const stopping = bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(calls.stop.length, 0, 'stop must not race an earlier steer submission');
|
||
|
||
steerRelease.resolve();
|
||
await Promise.all([steering, stopping]);
|
||
await bridge.waitForIdle();
|
||
assert.deepEqual(order, ['steer:start', 'steer:end', 'stop']);
|
||
});
|
||
|
||
test('pending question blocks card steer and card stop cancels the question', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const questionReady = deferred();
|
||
const cancelled = deferred();
|
||
const { calls, harness } = activeTurnHarness();
|
||
harness.ask = async (sessionId, _text, options) => {
|
||
await options.onInteraction({
|
||
kind: 'question',
|
||
interactionId: 'card-question',
|
||
rpcId: 'card-question',
|
||
sessionId,
|
||
payload: {
|
||
type: 'question/requested',
|
||
sessionId,
|
||
questions: [{ id: 'answer', question: 'Please answer before continuing' }],
|
||
},
|
||
respond: async (result) => {
|
||
cancelled.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
questionReady.resolve();
|
||
await cancelled.promise;
|
||
const error = new Error('turn stopped');
|
||
error.code = 'turn-stopped';
|
||
throw error;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('question-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const turn = bridge.accept(event('question-task-start', 'start a question', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await questionReady.promise;
|
||
|
||
await bridge.accept(event('question-new-blocked', '/new', { senderOpenId: 'ou_owner' }));
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||
assert.equal(
|
||
fixture.sessions.get('p2p:ou_owner'),
|
||
'session-active',
|
||
'text and card new-session actions must not clear a pending interaction',
|
||
);
|
||
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||
assert.equal(calls.steer.length, 0, 'a pending question must block card steer');
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /等待你的回答或审批/);
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
const cancellation = await cancelled.promise;
|
||
await turn;
|
||
|
||
assert.equal(calls.stop.length, 1);
|
||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||
assert.deepEqual(cancellation, {
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'The Feishu interaction ended before the user answered.',
|
||
details: {},
|
||
},
|
||
});
|
||
});
|
||
|
||
test('pending approval blocks card steer and card stop rejects the approval', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const approvalReady = deferred();
|
||
const decided = deferred();
|
||
const { calls, harness } = activeTurnHarness();
|
||
harness.ask = async (sessionId, _text, options) => {
|
||
await options.onInteraction({
|
||
kind: 'approval',
|
||
interactionId: 'card-approval',
|
||
rpcId: 'card-approval-rpc',
|
||
sessionId,
|
||
payload: {
|
||
type: 'approval/requested',
|
||
sessionId,
|
||
approvalId: 'card-approval',
|
||
toolName: 'bash',
|
||
callId: 'card-approval-call',
|
||
reason: 'Run a protected command',
|
||
},
|
||
toolCall: {
|
||
callId: 'card-approval-call',
|
||
name: 'bash',
|
||
arguments: JSON.stringify({ command: 'true' }),
|
||
},
|
||
respond: async (result) => {
|
||
decided.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
approvalReady.resolve();
|
||
await decided.promise;
|
||
const error = new Error('turn stopped');
|
||
error.code = 'turn-stopped';
|
||
throw error;
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('approval-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
const turn = bridge.accept(event('approval-card-task', 'start an approval', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await approvalReady.promise;
|
||
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||
assert.equal(calls.steer.length, 0, 'a pending approval must block card steer');
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /等待你的回答或审批/);
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
const decision = await decided.promise;
|
||
await turn;
|
||
|
||
assert.equal(calls.stop.length, 1);
|
||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||
assert.deepEqual(decision, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-active',
|
||
approvalId: 'card-approval',
|
||
outcome: 'rejected',
|
||
},
|
||
});
|
||
});
|
||
|
||
test('menu stop button stops the bound active turn without touching the model', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-stop-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.length, 1);
|
||
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(calls.sessions.some((id) => id === 'session-active'), true);
|
||
assert.equal(calls.stop.length, 1);
|
||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /已请求停止当前任务/);
|
||
});
|
||
|
||
test('menu steer dropdown sends a quick instruction to the active turn', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-steer-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.length, 1);
|
||
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(calls.steer.length, 1);
|
||
assert.equal(calls.steer[0].text, '继续');
|
||
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
|
||
});
|
||
|
||
test('menu steer custom option opens the form card without steering', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-steer-custom-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(calls.steer.length, 0, 'custom option must not steer yet');
|
||
const customCard = sent.at(-1);
|
||
assert.equal(customCard.msgType, 'interactive');
|
||
const form = customCard.content.body.elements.find((el) => el.tag === 'form');
|
||
assert.ok(form, 'custom steer card must contain a form');
|
||
const submit = form.elements.find((el) => el.tag === 'button');
|
||
assert.equal(submit?.name, 'steer_submit');
|
||
assert.equal(submit?.form_action_type, 'submit');
|
||
assert.equal(submit?.action_type, undefined);
|
||
const controlNames = [form.name, ...form.elements.map((element) => element.name).filter(Boolean)];
|
||
assert.equal(new Set(controlNames).size, controlNames.length);
|
||
});
|
||
|
||
test('custom steer form submission reads form_value and steers the active turn', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('steer-form-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
// 主菜单 steer 下拉选「更多/自定义」→ 发送 custom 表单卡(第二次 create → om_card_2)
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(sent.at(-1).msgType, 'interactive');
|
||
|
||
await bridge.onCardAction({
|
||
open_id: 'ou_owner',
|
||
open_message_id: 'om_card_2',
|
||
open_chat_id: 'oc_chat',
|
||
action: {
|
||
value: JSON.stringify({ action: 'steer', source: 'form' }),
|
||
form_value: JSON.stringify({ steer_text: '更简洁些' }),
|
||
},
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(calls.steer.length, 1);
|
||
assert.equal(calls.steer[0].text, '更简洁些');
|
||
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
|
||
});
|
||
|
||
test('custom steer form accepts the official Card 2.0 object callback payload', async () => {
|
||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||
const sent = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('steer-form-object-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
|
||
await bridge.onCardAction({
|
||
operator: { open_id: 'ou_owner' },
|
||
context: { open_message_id: 'om_card_2', open_chat_id: 'oc_chat' },
|
||
action: {
|
||
name: 'steer_submit',
|
||
tag: 'button',
|
||
value: { action: 'steer', source: 'form' },
|
||
form_value: { steer_text: '更详细些' },
|
||
},
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.equal(calls.steer.length, 1);
|
||
assert.equal(calls.steer[0].text, '更详细些');
|
||
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
|
||
});
|
||
|
||
test('menu stop and steer reply friendly when no session is bound', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const { calls, harness } = activeTurnHarness();
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
harness,
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('menu-nosession-open', '/m', { senderOpenId: 'ou_owner' }));
|
||
await bridge.waitForIdle();
|
||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(calls.stop.length, 0);
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /没有正在运行/);
|
||
|
||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||
await bridge.waitForIdle();
|
||
assert.equal(calls.steer.length, 0);
|
||
assert.match(JSON.parse(sent.at(-1).content).text, /没有绑定会话/);
|
||
});
|