mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
6150 lines
216 KiB
JavaScript
6150 lines
216 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';
|
||
import {
|
||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||
directAccessPolicy,
|
||
} from '../access-policy-fixture.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('Feishu applies the unified access policy before attachments or Harness work', async () => {
|
||
const fixture = stateFixture([['p2p:ou_member', 'session-member']]);
|
||
let downloads = 0;
|
||
const harnessCalls = [];
|
||
const sent = [];
|
||
const accessPolicy = directAccessPolicy({
|
||
users: [{ id: 'ou_member', canExecuteCommands: false }],
|
||
privilegedIds: ['ou_owner'],
|
||
});
|
||
const client = {
|
||
im: { v1: {
|
||
messageResource: { get: async () => {
|
||
downloads += 1;
|
||
return { getReadableStream: () => Readable.from([PNG_1X1]) };
|
||
} },
|
||
message: { create: async (request) => {
|
||
sent.push(JSON.parse(request.data.content).text);
|
||
return { code: 0, data: { message_id: `om_policy_${sent.length}` } };
|
||
} },
|
||
} },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: {},
|
||
accessPolicy,
|
||
harness: {
|
||
sessionExists: async (sessionId) => {
|
||
harnessCalls.push(['sessionExists', sessionId]);
|
||
return true;
|
||
},
|
||
ask: async (sessionId, prompt) => {
|
||
harnessCalls.push(['ask', sessionId, prompt]);
|
||
return '白名单消息已处理';
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status: bridgeStatus(),
|
||
});
|
||
|
||
await bridge.accept(event('policy-blocked-image', '', {
|
||
senderOpenId: 'ou_blocked',
|
||
message_type: 'image',
|
||
content: JSON.stringify({ image_key: 'img_blocked' }),
|
||
}));
|
||
await bridge.waitForIdle();
|
||
assert.equal(downloads, 0);
|
||
assert.deepEqual(harnessCalls, []);
|
||
assert.deepEqual(sent, []);
|
||
|
||
await bridge.accept(event('policy-member-text', '普通消息', {
|
||
senderOpenId: 'ou_member',
|
||
}));
|
||
await bridge.waitForIdle();
|
||
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
|
||
assert.deepEqual(sent, ['白名单消息已处理']);
|
||
|
||
const callsBeforeDeniedCommand = harnessCalls.length;
|
||
const repliesBeforeDeniedCommand = sent.length;
|
||
await bridge.accept(event('policy-member-command', '/help', {
|
||
senderOpenId: 'ou_member',
|
||
}));
|
||
await bridge.waitForIdle();
|
||
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
|
||
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
|
||
|
||
accessPolicy.getSettings().direct.allowlist.users = [];
|
||
await bridge.accept(event('policy-owner-command', '/help', {
|
||
senderOpenId: 'ou_owner',
|
||
}));
|
||
await bridge.waitForIdle();
|
||
assert.match(sent.at(-1), /\/status/);
|
||
});
|
||
|
||
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('bind confirmations from card actions are threaded to the card message', async (t) => {
|
||
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) => {
|
||
const sentMessageId = `om_replied_${replied.length + 1}`;
|
||
replied.push({
|
||
id: sentMessageId,
|
||
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: sentMessageId } };
|
||
},
|
||
} } },
|
||
},
|
||
harness: {
|
||
ensureRunning: async () => true,
|
||
bindWorkspaceSession: async (key, sessionId) => ({ sessionId, title: 'Test Session' }),
|
||
},
|
||
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: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_user']),
|
||
});
|
||
|
||
await bridge.accept(event('om_cmd', '/new', {
|
||
chat_type: 'group',
|
||
chat_id: 'oc_topic',
|
||
thread_id: 'omt_cmd',
|
||
}));
|
||
await eventually(() => replied.length >= 1, 'the menu card was not created');
|
||
const menuCardMessageId = replied.filter((item) => item.type === 'interactive').at(-1).id;
|
||
|
||
await bridge.onCardAction(cardActionEvent(menuCardMessageId, 'use:session-1', 'ou_user'));
|
||
await eventually(
|
||
() => replied.some(({ to, text }) => to === menuCardMessageId && text?.includes('已绑定会话')),
|
||
'the bind confirmation must be threaded to the card message',
|
||
);
|
||
assert.equal(
|
||
created.some(({ text }) => text?.includes('已绑定会话')),
|
||
false,
|
||
'the bind confirmation must not be sent as a fresh chat message outside the topic',
|
||
);
|
||
});
|
||
|
||
test('watch completion pushes are threaded to the message that created the watch', async (t) => {
|
||
const { state } = await watchStoreFixture();
|
||
const harness = watchHarness({
|
||
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'race-session', title: 'Race Session' }] },
|
||
});
|
||
const completionReplies = [];
|
||
const completionCreates = [];
|
||
const client = {
|
||
im: { v1: { message: {
|
||
create: async (request) => {
|
||
completionCreates.push(request.data.msg_type);
|
||
return { code: 0, data: { message_id: `om_created_${completionCreates.length}` } };
|
||
},
|
||
reply: async (request) => {
|
||
completionReplies.push({ to: request.path.message_id, type: request.data.msg_type });
|
||
return { code: 0, data: { message_id: `om_replied_${completionReplies.length}` } };
|
||
},
|
||
} } },
|
||
};
|
||
const bridge = new FeishuHarnessBridge({
|
||
client,
|
||
channel: {},
|
||
harness,
|
||
state,
|
||
status: bridgeStatus(),
|
||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||
});
|
||
|
||
await bridge.accept(event('watch-anchor', '/watch 1', { senderOpenId: 'ou_owner' }));
|
||
const entry = state.watchEntry('p2p:ou_owner', 'race-session');
|
||
assert.equal(entry?.replyToMessageId, 'watch-anchor', 'the watch entry must remember the anchor message');
|
||
|
||
harness._listeners[0].onSessionEvent({
|
||
sessionId: 'race-session',
|
||
event: { type: 'turn/end', seq: 10, time: entry.watchStartedAt, data: { reason: { kind: 'completed' } } },
|
||
});
|
||
await eventually(
|
||
() => completionReplies.some(({ type }) => type === 'interactive'),
|
||
'the completion card was not delivered',
|
||
);
|
||
assert.equal(
|
||
completionReplies.at(-1).to,
|
||
'watch-anchor',
|
||
'the completion push must be threaded to the /watch command message',
|
||
);
|
||
assert.equal(
|
||
completionCreates.length,
|
||
0,
|
||
'the completion push must not be sent as a fresh 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('a card callback without a trusted route stays silent before access evaluation', async () => {
|
||
const sent = [];
|
||
const bridge = new FeishuHarnessBridge({
|
||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||
channel: {},
|
||
accessPolicy: directAccessPolicy({
|
||
users: [{ id: 'ou_member', canExecuteCommands: true }],
|
||
privilegedIds: ['ou_owner'],
|
||
}),
|
||
harness: sessionsHarness(1),
|
||
state: stateFixture().state,
|
||
status: bridgeStatus(),
|
||
});
|
||
|
||
await bridge.onCardAction({
|
||
...cardActionEvent('om_stale_after_restart', 'new', 'ou_member'),
|
||
context: {
|
||
open_message_id: 'om_stale_after_restart',
|
||
open_chat_id: 'oc_untrusted_scope',
|
||
},
|
||
});
|
||
await bridge.waitForIdle();
|
||
|
||
assert.deepEqual(sent, [], 'missing direct/group scope must fail closed without a reply');
|
||
});
|
||
|
||
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, /没有绑定会话/);
|
||
});
|