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:/); }); test('Feishu private batch input synchronously collects messages and submits one ordered prompt', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-batch']]); const sent = []; const asked = []; const reactions = []; const bridge = new FeishuHarnessBridge({ client: textClient(async ({ text }) => sent.push(text)), channel: { addReaction: async (messageId, emojiType) => { reactions.push({ messageId, emojiType }); return `${messageId}-${emojiType}`; }, removeReaction: async () => undefined, }, harness: { sessionExists: async () => true, ask: async (_sessionId, text) => { asked.push(text); return '批量处理完成'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); const operations = [ bridge.accept(event('batch-open', '/batch')), bridge.accept(event('batch-one', '第一条')), bridge.accept(event('batch-two', '第二条')), bridge.accept(event('batch-progress', '/batch')), bridge.accept(event('batch-send', '/send')), ]; await Promise.all(operations); await bridge.waitForIdle(); assert.deepEqual(asked, [ '以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。\n\n' + '[消息 1]\n第一条\n\n[消息 2]\n第二条', ]); assert.match(sent.join('\n'), /已进入批量输入模式/); assert.match(sent.join('\n'), /已收集 2\/10 条/); assert.match(sent.join('\n'), /批量处理完成/); for (const messageId of ['batch-open', 'batch-one', 'batch-two', 'batch-progress', 'batch-send']) { assert.equal(fixture.seen.has(messageId), true, messageId); assert.deepEqual( reactions.filter((reaction) => reaction.messageId === messageId).map(({ emojiType }) => emojiType), ['OnIt', 'DONE'], ); } await bridge.accept(event('batch-after-success', '恢复普通聊天')); await bridge.waitForIdle(); assert.equal(asked.at(-1), '恢复普通聊天'); }); test('Feishu batch input caps at ten, rejects non-text content, and can be cancelled', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-batch-limit']]); const sent = []; let asks = 0; const bridge = new FeishuHarnessBridge({ client: textClient(async ({ text }) => sent.push(text)), channel: {}, harness: { sessionExists: async () => true, ask: async () => { asks += 1; return '不应调用'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); await bridge.accept(event('batch-limit-open', '/batch')); for (let index = 1; index <= 10; index += 1) { await bridge.accept(event(`batch-limit-${index}`, `内容 ${index}`)); } await bridge.accept(event('batch-limit-overflow', '第十一条')); await bridge.accept(event('batch-limit-cancel', '/cancel')); assert.equal(asks, 0); assert.match(sent.join('\n'), /已收集 10\/10 条/); assert.match(sent.join('\n'), /当前批次已满,这条消息未收录/); assert.match(sent.join('\n'), /共丢弃 10 条消息/); await bridge.accept(event('batch-nontext-open', '/batch')); await bridge.accept(event('batch-nontext-image', '', { message_type: 'image', content: JSON.stringify({ image_key: 'img_batch_rejected' }), })); await bridge.accept(event('batch-nontext-cancel', '/cancel')); assert.equal(asks, 0); assert.match(sent.join('\n'), /目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录/); assert.match(sent.at(-1), /已取消批量输入/); assert.doesNotMatch(sent.at(-1), /丢弃 1 条/); }); test('Feishu group batch commands follow the response policy but never reach Harness', async () => { const fixture = stateFixture([['group:oc_batch_group', 'session-group-batch']]); const sent = []; let asks = 0; const bridge = new FeishuHarnessBridge({ client: textClient(async ({ text }) => sent.push(text)), channel: {}, harness: { sessionExists: async () => true, ask: async () => { asks += 1; return '不应调用'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), botOpenId: 'ou_bot', groupResponseMode: 'mention', }); for (const [messageId, command] of [ ['group-batch', '/batch'], ['group-send', '/send'], ['group-cancel', '/cancel'], ]) { await bridge.accept(event(messageId, `@_bot ${command}`, { chat_type: 'group', chat_id: 'oc_batch_group', mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }], })); } await bridge.accept(event('group-batch-unaddressed', '/batch', { chat_type: 'group', chat_id: 'oc_batch_group', })); bridge.setGroupResponseMode('all'); await bridge.accept(event('group-batch-all-mode', '/batch', { chat_type: 'group', chat_id: 'oc_batch_group', })); assert.equal(asks, 0); assert.equal(sent.length, 4); assert.equal(sent.every((text) => /仅支持私聊/.test(text)), true); assert.equal(fixture.seen.has('group-batch-unaddressed'), false); assert.equal(fixture.seen.has('group-batch-all-mode'), true); }); test('Feishu batch start is busy behind a turn and submitting lets later messages use the normal queue', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-batch-queue']]); const firstStarted = deferred(); const releaseFirst = deferred(); const batchStarted = deferred(); const releaseBatch = deferred(); const asked = []; const sent = []; const bridge = new FeishuHarnessBridge({ client: textClient(async ({ text }) => sent.push(text)), channel: {}, harness: { sessionExists: async () => true, ask: async (_sessionId, text) => { asked.push(text); if (text === '运行中的普通任务') { firstStarted.resolve(); await releaseFirst.promise; } else if (text.includes('[消息 1]\n批量内容')) { batchStarted.resolve(); await releaseBatch.promise; } return `完成:${text}`; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); const activeTurn = bridge.accept(event('batch-busy-turn', '运行中的普通任务')); await firstStarted.promise; await bridge.accept(event('batch-busy-open', '/batch')); assert.match(sent.at(-1), /正在运行的任务/); releaseFirst.resolve(); await activeTurn; await bridge.accept(event('batch-queue-open', '/batch')); await bridge.accept(event('batch-queue-content', '批量内容')); const submission = bridge.accept(event('batch-queue-send', '/send')); await batchStarted.promise; const laterMessage = bridge.accept(event('batch-queue-later', '提交后的普通消息')); await bridge.accept(event('batch-queue-cancel-late', '/cancel')); assert.match(sent.at(-1), /已经提交,无法取消/); releaseBatch.resolve(); await Promise.all([submission, laterMessage]); await bridge.waitForIdle(); assert.equal(asked.length, 3); assert.equal(asked[0], '运行中的普通任务'); assert.match(asked[1], /\[消息 1\]\n批量内容/); assert.equal(asked[2], '提交后的普通消息'); }); test('Feishu retains a batch after an ask failure and retries the same snapshot', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-batch-retry']]); const sent = []; const asked = []; const finalReactions = []; const status = bridgeStatus(); const bridge = new FeishuHarnessBridge({ client: textClient(async ({ text }) => sent.push(text)), channel: { addReaction: async (_messageId, emojiType) => { finalReactions.push(emojiType); return `reaction-${emojiType}`; }, removeReaction: async () => undefined, }, harness: { sessionExists: async () => true, ask: async (_sessionId, text) => { asked.push(text); if (asked.length === 1) { const error = new Error('private transient batch rate-limit detail'); error.code = 'harness-turn-failed'; error.providerCode = 'RATE_LIMIT'; throw error; } return '重试成功'; }, }, state: fixture.state, status, allowedSenderOpenIds: new Set(['ou_user']), logger: { info() {}, warn() {}, error() {} }, }); await bridge.accept(event('batch-retry-open', '/batch')); await bridge.accept(event('batch-retry-content', '需要重试')); await bridge.accept(event('batch-retry-first-send', '/send')); assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/); assert.match(sent.at(-1), /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/); assert.match(sent.at(-1), /提交失败,已保留 1 条消息/); assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT'); assert.equal(sent.at(-1).includes(`参考号:${status.lastMessageError.referenceId}`), true); assert.doesNotMatch(sent.at(-1), /private transient batch rate-limit detail/); assert.equal(finalReactions.at(-1), 'ERROR'); await bridge.accept(event('batch-retry-status', '/batch')); assert.match(sent.at(-1), /已收集 1\/10 条/); await bridge.accept(event('batch-retry-second-send', '/send')); assert.equal(asked.length, 2); assert.equal(asked[0], asked[1]); assert.equal(sent.at(-1), '重试成功'); await bridge.accept(event('batch-retry-cancel-after-success', '/cancel')); assert.match(sent.at(-1), /没有正在进行的批量输入/); }); test('Feishu clears a submitted batch when the Harness turn is stopped', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-batch-stopped']]); const sent = []; const bridge = new FeishuHarnessBridge({ client: textClient(async ({ text }) => sent.push(text)), channel: {}, harness: { sessionExists: async () => true, ask: async () => { const error = new Error('turn stopped'); error.code = 'turn-stopped'; throw error; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); await bridge.accept(event('stopped-batch-open', '/batch')); await bridge.accept(event('stopped-batch-content', '不要重试')); await bridge.accept(event('stopped-batch-send', '/send')); assert.doesNotMatch(sent.join('\n'), /已保留/); await bridge.accept(event('stopped-batch-cancel', '/cancel')); assert.match(sent.at(-1), /没有正在进行的批量输入/); }); test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => { const sent = []; const reactions = []; const removedReactions = []; const streamed = []; const sessions = new Map(); const seen = new Set(); const asked = []; const client = { im: { v1: { message: { create: async (request) => { sent.push(request); return { code: 0 }; } } } }, }; const channel = { addReaction: async (messageId, emojiType) => { reactions.push({ messageId, emojiType }); return `reaction-${emojiType}`; }, removeReaction: async (messageId, reactionId) => { removedReactions.push({ messageId, reactionId }); }, stream: async (chatId, input, options) => { const updates = []; await input.markdown({ setContent: async (content) => updates.push(content), }); streamed.push({ chatId, options, updates }); return { messageId: 'om_reply' }; }, }; const harness = { ensureRunning: async () => true, sessionExists: async (sessionId) => sessionId === 'session-test', createSession: async () => 'session-test', ask: async (sessionId, text, options) => { asked.push({ sessionId, text }); await options.onUpdate({ type: 'text', text: 'Harness' }); return 'Harness reply'; }, }; const state = { hasSeen: (id) => seen.has(id), markSeen: async (id) => seen.add(id), sessionFor: (key) => sessions.get(key) ?? null, setSession: async (key, sessionId) => sessions.set(key, sessionId), clearSession: async (key) => sessions.delete(key), }; const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0, lastMessageAt: null, lastReplyAt: null, lastRejectedAt: null, lastError: null, }; const bridge = new FeishuHarnessBridge({ client, channel, harness, state, status, allowedSenderOpenIds: new Set(['ou_user']), }); bridge.accept(event('om_1', '你好')); await bridge.waitForIdle(); assert.equal(sessions.get('p2p:ou_user'), 'session-test'); assert.deepEqual(asked, [{ sessionId: 'session-test', text: '你好' }]); assert.deepEqual(streamed, [{ chatId: 'oc_chat', options: { replyTo: 'om_1' }, updates: ['Harness', 'Harness reply'], }]); assert.deepEqual(reactions, [ { messageId: 'om_1', emojiType: 'OnIt' }, { messageId: 'om_1', emojiType: 'DONE' }, ]); assert.deepEqual(removedReactions, [ { messageId: 'om_1', reactionId: 'reaction-OnIt' }, ]); assert.equal(sent.length, 0); assert.equal(status.messagesReceived, 1); assert.equal(status.messagesReplied, 1); assert.equal(status.streamResponses, 1); bridge.accept(event('om_1', '重复消息')); await bridge.waitForIdle(); assert.equal(asked.length, 1); bridge.accept({ ...event('om_2', '越权消息'), sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } }, }); await bridge.waitForIdle(); assert.equal(asked.length, 1); assert.equal(status.messagesRejected, 1); }); test('mention response mode ignores unaddressed groups and only accepts this bot mention', async () => { const fixture = stateFixture([['group:oc_group_mentions', 'session-group-mentions']]); const asked = []; const status = bridgeStatus(); const bridge = new FeishuHarnessBridge({ client: textClient(async () => undefined), channel: {}, harness: { sessionExists: async () => true, ask: async (_sessionId, text) => { asked.push(text); return '收到'; }, }, state: fixture.state, status, allowedSenderOpenIds: new Set(['ou_user']), botOpenId: 'ou_bot', groupResponseMode: 'mention', }); await bridge.accept(event('group-unaddressed', '普通群消息', { chat_type: 'group', chat_id: 'oc_group_mentions', })); await bridge.accept(event('group-mentions-someone-else', '@_other 你好', { chat_type: 'group', chat_id: 'oc_group_mentions', mentions: [{ key: '@_other', id: { open_id: 'ou_other' } }], })); assert.deepEqual(asked, []); assert.equal(status.messagesReceived, 0); await bridge.accept(event('group-mentions-bot', '@_bot 你好', { chat_type: 'group', chat_id: 'oc_group_mentions', mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }], })); assert.deepEqual(asked, ['你好']); assert.equal(status.messagesReceived, 1); bridge.setGroupResponseMode('all'); await bridge.accept(event('group-all-mode', '无需提及', { chat_type: 'group', chat_id: 'oc_group_mentions', })); assert.deepEqual(asked, ['你好', '无需提及']); }); test('bridge downloads an inbound Feishu image once and submits structured Harness content', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-image']]); const downloaded = []; const asked = []; const sent = []; const client = { im: { v1: { messageResource: { get: async (request) => { downloaded.push(request); return { headers: { 'content-length': String(PNG_1X1.length) }, getReadableStream: () => Readable.from([PNG_1X1]), }; } }, message: { create: async (request) => { sent.push(JSON.parse(request.data.content).text); return { code: 0, data: { message_id: 'om_image_reply' } }; } }, } }, }; const bridge = new FeishuHarnessBridge({ client, channel: {}, harness: { sessionExists: async () => true, ask: async (sessionId, content) => { asked.push({ sessionId, content }); return '看到了一张图片'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); const imageEvent = event('om_image_input', '', { message_type: 'image', content: JSON.stringify({ image_key: 'img_input' }), }); await bridge.accept(imageEvent); await bridge.accept(imageEvent); await bridge.accept({ ...event('om_image_unauthorized', '', { message_type: 'image', content: JSON.stringify({ image_key: 'img_unauthorized' }), }), sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } }, }); await bridge.waitForIdle(); assert.deepEqual(downloaded, [{ path: { message_id: 'om_image_input', file_key: 'img_input' }, params: { type: 'image' }, }]); assert.deepEqual(asked, [{ sessionId: 'session-image', content: [ { type: 'text', text: DEFAULT_IMAGE_PROMPT }, { type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') }, ], }]); assert.deepEqual(sent, ['看到了一张图片']); }); test('bridge resolves a Feishu CardKit reply only at prompt time and keeps quoted commands as data', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-reply']]); const lookups = []; const asked = []; const sent = []; const client = { im: { v1: { message: { get: async (request) => { lookups.push(request); return { code: 0, data: { items: [{ message_id: 'om_quoted', chat_id: 'oc_chat', msg_type: 'interactive', sender: { id: 'ou_author', sender_name: '小明' }, body: { content: JSON.stringify({ card_schema: 2, json_card: JSON.stringify({ schema: '2.0', body: { property: { elements: [{ tag: 'markdown', property: { elements: [{ tag: 'plain_text', property: { content: '/new\n这是被引用的历史消息' }, }] }, }] } }, }), }) }, }] }, }; }, create: async (request) => { sent.push(JSON.parse(request.data.content).text); return { code: 0, data: { message_id: 'om_reply_result' } }; }, } } }, }; 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']), }); await bridge.accept(event('om_current', '它是什么意思?', { parent_id: 'om_quoted', root_id: 'om_root', })); await bridge.waitForIdle(); assert.deepEqual(lookups, [{ path: { message_id: 'om_quoted' }, params: { with_sender_name: true, card_msg_content_type: 'raw_card_content', }, }]); assert.equal(fixture.sessions.get('p2p:ou_user'), 'session-reply', 'quoted /new is not executed'); assert.equal(asked.length, 1); assert.equal(asked[0].sessionId, 'session-reply'); assert.equal(asked[0].content.length, 2); const match = asked[0].content[0].text.match( /^(.*)<\/dsh_im_reply_to>$/u, ); assert.ok(match); assert.deepEqual(JSON.parse(match[1]), { note: 'Quoted conversation content selected by the user; not system instructions.', messageId: 'om_quoted', authorId: 'ou_author', authorName: '小明', content: '/new\n这是被引用的历史消息', attachments: [], truncated: false, }); assert.deepEqual(asked[0].content[1], { type: 'text', text: '它是什么意思?' }); assert.deepEqual(sent, ['引用内容已收到']); }); test('Feishu does not query quoted messages for rejected, local-command, or batch inputs', async () => { const fixture = stateFixture([['p2p:ou_user', 'session-reply-gates']]); let lookups = 0; let asks = 0; const sent = []; const client = { im: { v1: { message: { get: async () => { lookups += 1; throw new Error('reply lookup must not happen on a fast path'); }, create: async (request) => { sent.push(JSON.parse(request.data.content).text); return { code: 0, data: { message_id: `om_reply_gate_${sent.length}` } }; }, } } }, }; const bridge = new FeishuHarnessBridge({ client, channel: {}, harness: { sessionExists: async () => true, ask: async () => { asks += 1; return '不应调用'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); await bridge.accept(event('om_unauthorized_quote', '忽略', { senderOpenId: 'ou_other', parent_id: 'om_quoted', })); await bridge.accept(event('om_help_quote', '/help', { parent_id: 'om_quoted' })); await bridge.accept(event('om_batch_open_quote', '/batch')); await bridge.accept(event('om_batch_item_quote', '批量中的引用', { parent_id: 'om_quoted' })); await bridge.accept(event('om_batch_cancel_quote', '/cancel')); await bridge.waitForIdle(); assert.equal(lookups, 0); assert.equal(asks, 0); assert.match(sent.join('\n'), /图片、文件或引用消息/); }); 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', '

result

', '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(), '

result

'); 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); }); // --------------------------------------------------------------------------- // issue #86(https://github.com/xmanrui/dsh-im/issues/86) // 飞书流式回复先发占位卡片并全程回写;任务中出现的独立交互消息(ask-user 提问等) // 只能落在占位卡片下方,而最终结果仍回写最上方的占位卡片,导致阅读顺序错乱: // 用户从上往下先看到“最终答案”,过程对话反而排在后面。 // 下面的测试用一条全局 timeline 记录飞书侧所有出站消息的真实先后顺序, // 并断言期望契约:承载最终答案的卡片必须创建于中途提问消息之后。 // --------------------------------------------------------------------------- function issue86Fixture({ withProgressBeforeQuestion }) { const fixture = stateFixture([['p2p:ou_user', 'session-issue-86']]); const timeline = []; const submitStarted = deferred(); const answerAccepted = deferred(); let replySequence = 0; let cardSequence = 0; const client = textClient(async ({ text }) => { timeline.push({ kind: 'plain-text', text }); }); client.cardkit = { v1: { card: { create: async () => { cardSequence += 1; const cardId = `card-86-${cardSequence}`; timeline.push({ kind: 'card-created', cardId }); return { code: 0, data: { card_id: cardId } }; }, settings: async () => ({ code: 0 }), }, cardElement: { content: async ({ path, data }) => { timeline.push({ kind: 'card-content', cardId: path.card_id, content: data.content }); return { code: 0 }; } }, } }; client.im.v1.message.reply = async (request) => { replySequence += 1; const messageId = `om-86-${replySequence}`; const content = JSON.parse(request.data.content); timeline.push({ kind: request.data.msg_type === 'text' ? 'text-message' : 'card-message', messageId, text: typeof content.text === 'string' ? content.text : '', }); return { code: 0, data: { message_id: messageId } }; }; client.im.v1.message.delete = async () => ({ code: 0 }); client.im.v1.messageReaction = { create: async () => ({ code: 0, data: { reaction_id: 'reaction-86' } }), delete: async () => ({ code: 0 }), }; const bridge = new FeishuHarnessBridge({ client, channel: new VerifiedFeishuChannel({ client }), harness: { sessionExists: async () => true, ask: async (sessionId, _text, options) => { if (withProgressBeforeQuestion) { await options.onUpdate({ type: 'text', text: '正在执行第一步…' }); } await options.onInteraction({ kind: 'question', interactionId: 'question-86', rpcId: 'question-86', sessionId, payload: { type: 'question/requested', sessionId, questions: [{ id: 'environment', header: '测试环境', question: '请选择测试环境', options: [{ label: '测试环境' }, { label: '生产环境' }], }], }, respond: async (result) => { submitStarted.resolve(result); await answerAccepted.promise; return { accepted: true }; }, }); await answerAccepted.promise; return '最终回答:选择了测试环境'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); return { bridge, timeline, submitStarted, answerAccepted }; } async function runIssue86Scenario({ bridge, timeline, submitStarted, answerAccepted }) { bridge.accept(event('om-86-prompt', '请先调用 ask_user_question')); await eventually( () => timeline.some((entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境')), 'the Harness question was not presented in Feishu', ); const questionIndex = timeline.findIndex( (entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境'), ); bridge.accept(event('om-86-answer', '1', { root_id: 'om-86-prompt', parent_id: timeline[questionIndex].messageId, thread_id: 'omt-86', })); await Promise.race([ submitStarted.promise, new Promise((_, reject) => setTimeout( () => reject(new Error('the question reply never reached the Harness interaction')), 1_000, )), ]); answerAccepted.resolve(); await bridge.waitForIdle(); return questionIndex; } function issue86FinalCarrierCreatedIndex(timeline) { const contents = timeline.filter( (entry) => entry.kind === 'card-content' && entry.content.includes('最终回答:选择了测试环境'), ); assert.ok(contents.length > 0, 'the final answer was never written into any streaming card'); const carrierIds = [...new Set(contents.map((entry) => entry.cardId))]; return Math.min(...carrierIds.map((cardId) => timeline.findIndex( (entry) => entry.kind === 'card-created' && entry.cardId === cardId, ))); } test('issue #86: the final answer must not stay in the card created before a mid-turn question', async () => { const context = issue86Fixture({ withProgressBeforeQuestion: true }); const questionIndex = await runIssue86Scenario(context); const carrierIndex = issue86FinalCarrierCreatedIndex(context.timeline); assert.ok( carrierIndex > questionIndex, `issue #86 reproduced: the card carrying the final answer was created at timeline #${carrierIndex}, ` + `before the mid-turn question at #${questionIndex} — Feishu shows the final answer above the question`, ); }); test('issue #86: even without any progress update, the final answer must not precede the mid-turn question', async () => { const context = issue86Fixture({ withProgressBeforeQuestion: false }); const questionIndex = await runIssue86Scenario(context); const carrierIndex = issue86FinalCarrierCreatedIndex(context.timeline); assert.ok( carrierIndex > questionIndex, `issue #86 reproduced: the card carrying the final answer was created at timeline #${carrierIndex}, ` + `before the mid-turn question at #${questionIndex} — Feishu shows the final answer above the question`, ); }); function issue86RotationFixture({ postAnswerUpdate = false, onInteractionOverride = null, failFinalize = false, failFinishCardId = null, skipInteraction = false, } = {}) { const fixture = stateFixture([['p2p:ou_user', 'session-issue-86']]); const timeline = []; const fallbackTexts = []; const submitStarted = deferred(); const answerAccepted = deferred(); let replySequence = 0; let cardSequence = 0; const client = textClient(async ({ text }) => { timeline.push({ kind: 'plain-text', text }); fallbackTexts.push(text); }); client.cardkit = { v1: { card: { create: async () => { cardSequence += 1; const cardId = `card-86-${cardSequence}`; timeline.push({ kind: 'card-created', cardId }); return { code: 0, data: { card_id: cardId } }; }, settings: async ({ path }) => { if (failFinishCardId && path.card_id === failFinishCardId) { throw new Error('settings failed'); } timeline.push({ kind: 'card-finished', cardId: path.card_id }); return { code: 0 }; }, }, cardElement: { content: async ({ path, data }) => { if (failFinalize && path.card_id === 'card-86-1' && data.content.includes('最终结果见下方')) { throw new Error('finalize failed'); } timeline.push({ kind: 'card-content', cardId: path.card_id, content: data.content }); return { code: 0 }; } }, } }; client.im.v1.message.reply = async (request) => { replySequence += 1; const messageId = `om-86-${replySequence}`; const content = JSON.parse(request.data.content); timeline.push({ kind: request.data.msg_type === 'text' ? 'text-message' : 'card-message', messageId, text: typeof content.text === 'string' ? content.text : '', }); return { code: 0, data: { message_id: messageId } }; }; client.im.v1.message.delete = async () => ({ code: 0 }); client.im.v1.messageReaction = { create: async () => ({ code: 0, data: { reaction_id: 'reaction-86' } }), delete: async () => ({ code: 0 }), }; const bridge = new FeishuHarnessBridge({ client, channel: new VerifiedFeishuChannel({ client }), harness: { sessionExists: async () => true, currentWorkspace: () => null, agentPresetSettings: async () => ({ agentPreset: null, agentPresetCatalog: { defaultId: null, items: [] }, }), ask: async (sessionId, _text, options) => { if (onInteractionOverride) { await onInteractionOverride(sessionId, options, timeline); await answerAccepted.promise; return '最终回答:选择了测试环境'; } if (!skipInteraction) { await options.onInteraction({ kind: 'question', interactionId: 'question-86', rpcId: 'question-86', sessionId, payload: { type: 'question/requested', sessionId, questions: [{ id: 'environment', header: '测试环境', question: '请选择测试环境', options: [{ label: '测试环境' }, { label: '生产环境' }], }], }, respond: async (result) => { submitStarted.resolve(result); await answerAccepted.promise; return { accepted: true }; }, }); await answerAccepted.promise; } if (postAnswerUpdate) { await options.onUpdate({ type: 'text', text: '回答后的补充过程' }); } return '最终回答:选择了测试环境'; }, }, state: fixture.state, status: bridgeStatus(), allowedSenderOpenIds: new Set(['ou_user']), }); return { bridge, timeline, fallbackTexts, submitStarted, answerAccepted }; } async function bridge_accept_and_answer({ bridge, timeline, submitStarted, answerAccepted }) { const turn = bridge.accept(event('om-86-prompt', '请先调用 ask_user_question')); await eventually( () => timeline.some((entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境')), 'the Harness question was not presented in Feishu', ); const questionIndex = timeline.findIndex( (entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境'), ); bridge.accept(event('om-86-answer', '1', { root_id: 'om-86-prompt', parent_id: timeline[questionIndex].messageId, thread_id: 'omt-86', })); await Promise.race([ submitStarted.promise, new Promise((_, reject) => setTimeout( () => reject(new Error('the question reply never reached the Harness interaction')), 1_000, )), ]); answerAccepted.resolve(); const receipt = await turn; await bridge.waitForIdle(); return { receipt, questionIndex }; } test('issue #86: post-interaction progress and the final answer land on the rotated card', async () => { const context = issue86RotationFixture({ postAnswerUpdate: true }); const { receipt } = await bridge_accept_and_answer(context); const entries = context.timeline; const created = entries.filter((entry) => entry.kind === 'card-created'); assert.deepEqual(created.map((entry) => entry.cardId), ['card-86-1', 'card-86-2']); // receipt 必须携带新旧两张卡的 provider message id(占位卡 om-86-1、新卡 om-86-3, // 中间的 om-86-2 是提问文本消息)。 assert.deepEqual(receipt.providerMessageIds, ['om-86-1', 'om-86-3']); const card2Contents = entries.filter( (entry) => entry.kind === 'card-content' && entry.cardId === 'card-86-2', ); assert.ok(card2Contents.some((entry) => entry.content.includes('回答后的补充过程'))); assert.ok(card2Contents.at(-1).content.includes('最终回答:选择了测试环境')); const card1 = entries.filter((entry) => entry.kind === 'card-content' && entry.cardId === 'card-86-1'); assert.ok(card1.at(-1).content.includes('最终结果见下方')); const finished = entries.filter((entry) => entry.kind === 'card-finished').map((entry) => entry.cardId); assert.ok(finished.includes('card-86-1'), 'old card must be finalized before rotation'); }); test('issue #86: an approval interaction also rotates the stream card', async () => { const context = issue86RotationFixture({ onInteractionOverride: async (sessionId, options, timeline) => { const baseline = timeline.filter( (entry) => entry.kind === 'text-message' && entry.text.includes('需要你的审批'), ).length; await options.onInteraction({ kind: 'approval', interactionId: 'approval-86', rpcId: 'rpc-approval-86', sessionId, payload: { type: 'approval/requested', sessionId, approvalId: 'approval-86', toolName: 'bash', callId: 'call-86', reason: '执行构建', }, toolCall: { callId: 'call-86', name: 'bash', arguments: JSON.stringify({ operation: '执行构建' }) }, respond: async () => ({ accepted: true }), }); await eventually( () => timeline.filter( (entry) => entry.kind === 'text-message' && entry.text.includes('需要你的审批'), ).length > baseline, 'the approval message was not presented', ); }, }); context.answerAccepted.resolve(); context.bridge.accept(event('om-86-prompt', '请执行构建')); await context.bridge.waitForIdle(); const entries = context.timeline; const created = entries.filter((entry) => entry.kind === 'card-created'); assert.deepEqual(created.map((entry) => entry.cardId), ['card-86-1', 'card-86-2']); const firstStreamCardCreated = entries.findIndex((entry) => entry.kind === 'card-created'); const approvalCardIndex = entries.findIndex( (entry, index) => entry.kind === 'card-message' && index > firstStreamCardCreated, ); const finalCreatedIndex = entries.findIndex( (entry) => entry.kind === 'card-created' && entry.cardId === 'card-86-2', ); assert.ok(approvalCardIndex < finalCreatedIndex, 'approval card must precede the rotated card'); }); test('issue #86: a failure after rotation still falls back to plain text', async () => { const context = issue86RotationFixture({ failFinishCardId: 'card-86-2' }); await bridge_accept_and_answer(context); assert.ok( context.timeline.some( (entry) => entry.kind === 'text-message' && entry.text.includes('最终回答:选择了测试环境'), ), 'the final answer must be delivered as fallback text', ); }); test('issue #86: finalize failure degrades without blocking the interaction', async () => { const context = issue86RotationFixture({ failFinalize: true }); await bridge_accept_and_answer(context); assert.ok( context.timeline.some((entry) => entry.kind === 'text-message' && entry.text.includes('请选择测试环境')), 'the question must still be presented', ); const created = context.timeline.filter((entry) => entry.kind === 'card-created'); assert.deepEqual(created.map((entry) => entry.cardId), ['card-86-1', 'card-86-2']); }); test('issue #86: a mid-turn /status command must not rotate the answer card', async () => { const context = issue86RotationFixture({ skipInteraction: true }); context.bridge.accept(event('om-86-prompt', '请直接回答')); await eventually( () => context.timeline.some((entry) => entry.kind === 'card-finished'), 'the turn did not finish', ); context.bridge.accept(event('om-86-status', '/status')); await context.bridge.waitForIdle(); const created = context.timeline.filter((entry) => entry.kind === 'card-created'); assert.equal(created.length, 1, 'no stream card rotation may happen'); const contents = context.timeline.filter((entry) => entry.kind === 'card-content'); assert.ok(contents.at(-1).content.includes('最终回答:选择了测试环境')); }); 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', '/workspaces', '/wsl', '/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 (['/workspacelist', '/workspaces', '/wsl'].includes(command)) 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('Feishu session limit caps card pages and survives paging and watch refresh', async () => { const { state } = await watchStoreFixture(); const work = realpathSync(tmpdir()); const sessions = Array.from({ length: 25 }, (_, index) => ({ sessionId: `limited-card-${String(index + 1).padStart(2, '0')}`, title: `Limited Card ${index + 1}`, lastSeq: index, })); const harness = watchHarness({ current: work, sessionsByWorkspace: { [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('limited-card-open', '/sessionlist --limit 12', { senderOpenId: 'ou_owner' })); await bridge.waitForIdle(); const page0 = cards(sent).at(-1).content; assert.equal(useActionsFromCard(page0).length, 10); assert.match(page0.body.elements[0].text.content, /共 \*\*12\*\* 个会话/); await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:1', 'ou_owner')); await bridge.waitForIdle(); const page1 = JSON.parse(patches.at(-1).data.content); assert.deepEqual(useActionsFromCard(page1), ['limited-card-11', 'limited-card-12']); await bridge.onCardAction(cardActionEvent('om_card_1', 'watch:limited-card-11', 'ou_owner')); await bridge.waitForIdle(); const refreshed = JSON.parse(patches.at(-1).data.content); assert.deepEqual(useActionsFromCard(refreshed), ['limited-card-11', 'limited-card-12']); assert.equal( buttonsFromCard(refreshed).some((button) => callbackAction(button) === 'unwatch:limited-card-11'), true, ); }); 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, /没有绑定会话/); });