fix: support Harness interactions in DingTalk and Feishu

This commit is contained in:
xmanrui 2026-08-18 13:03:15 +08:00
parent c9ec84cc67
commit 3e7e1dcdd7
13 changed files with 5061 additions and 881 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,14 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
class FakeClient {}
class FakeClient {
static instances = [];
constructor(options) {
this.options = options;
FakeClient.instances.push(this);
}
}
class FakeDispatcher {
register(handlers) {
@ -40,24 +47,39 @@ class FakeWSClient {
function fakeLark() {
FakeWSClient.instances.length = 0;
FakeClient.instances.length = 0;
return {
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
LoggerLevel: { info: 'info' },
Client: FakeClient,
EventDispatcher: FakeDispatcher,
WSClient: FakeWSClient,
defaultHttpInstance: {
request: async (options) => options,
get: async (_url, options) => options,
delete: async (_url, options) => options,
head: async (_url, options) => options,
options: async (_url, options) => options,
post: async (_url, _data, options) => options,
put: async (_url, _data, options) => options,
patch: async (_url, _data, options) => options,
},
};
}
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
let harnessChecks = 0;
let harnessSignal;
const runtime = new FeishuRuntime({
lark: fakeLark(),
appId: 'cli_test',
appSecret: 'secret',
ownerOpenId: 'ou_owner',
harness: {
async ensureRunning() { harnessChecks += 1; },
async ensureRunning(options) {
harnessChecks += 1;
harnessSignal = options.signal;
},
},
state: { hasSeen: () => false },
});
@ -77,11 +99,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
assert.equal(status.ready, true);
assert.equal(status.feishuLongConnectionState, 'connected');
assert.equal(status.harnessReachable, true);
assert.equal(harnessSignal.aborted, false);
assert.equal((await FakeClient.instances[0].options.httpInstance.request({
url: 'https://open.feishu.cn/test',
})).timeout, 15_000);
const stopped = await runtime.stop();
assert.equal(stopped.ready, false);
assert.equal(stopped.feishuLongConnectionState, 'idle');
assert.equal(FakeWSClient.instances[0].state, 'closed');
assert.equal(harnessSignal.aborted, true);
});
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {

View file

@ -5,6 +5,160 @@ import {
HarnessReplyTracker,
} from '../../../src/channels/feishu/harness-client.mjs';
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);
}
class FakeSocket {
#listeners = new Map();
readyState = 0;
addEventListener(name, listener) {
const listeners = this.#listeners.get(name) ?? new Set();
listeners.add(listener);
this.#listeners.set(name, listeners);
}
removeEventListener(name, listener) {
this.#listeners.get(name)?.delete(listener);
}
open() {
if (this.readyState !== 0) return;
this.readyState = 1;
this.#emit('open', {});
}
frame(value) {
this.#emit('message', { data: JSON.stringify(value) });
}
close(code = 1000) {
if (this.readyState >= 2) return;
this.readyState = 3;
this.#emit('close', { code });
}
#emit(name, event) {
for (const listener of [...(this.#listeners.get(name) ?? [])]) listener(event);
}
}
test('interaction watcher uses the real Harness wire protocol and leaves approvals fail-closed', async () => {
const requests = [];
const opened = deferred();
let socket;
let socketUrl;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080/base',
workspace: '/tmp/dsh-feishu-workspace',
fetchImpl: async (url, options) => {
requests.push({
url: url.toString(),
method: options.method,
body: JSON.parse(options.body),
});
return { ok: true, json: async () => ({ accepted: true }) };
},
createWebSocket: (url) => {
socketUrl = url;
socket = new FakeSocket();
queueMicrotask(() => socket.open());
return socket;
},
});
const controller = new AbortController();
const interactions = [];
const watching = client.watchInteractions('session-feishu', {
signal: controller.signal,
onOpen: opened.resolve,
onInteraction: (interaction) => interactions.push(interaction),
});
await opened.promise;
socket.frame({
type: 'server-request',
rpcId: 'approval-rpc',
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'session-feishu',
approvalId: 'approval-one',
toolName: 'bash',
},
});
socket.frame({
type: 'server-request',
rpcId: 'question-rpc',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'session-feishu',
questions: [{
id: 'environment',
question: '请选择测试环境',
options: [{ label: '测试环境' }, { label: '生产环境' }],
}],
},
});
await eventually(() => interactions.length === 2);
assert.equal(socketUrl, 'ws://127.0.0.1:3080/api/events.mux');
assert.deepEqual(interactions.map((interaction) => ({
kind: interaction.kind,
interactionId: interaction.interactionId,
rpcId: interaction.rpcId,
sessionId: interaction.sessionId,
})), [
{
kind: 'approval',
interactionId: 'approval-one',
rpcId: 'approval-rpc',
sessionId: 'session-feishu',
},
{
kind: 'question',
interactionId: 'question-rpc',
rpcId: 'question-rpc',
sessionId: 'session-feishu',
},
]);
assert.equal(requests.length, 0, 'receiving an approval must never approve it automatically');
const result = {
ok: true,
value: {
sessionId: 'session-feishu',
answer: { answers: [{ id: 'environment', selected: ['测试环境'] }] },
},
};
assert.deepEqual(await interactions[1].respond(result), { accepted: true });
assert.deepEqual(requests, [{
url: 'http://127.0.0.1:3080/api/respond',
method: 'POST',
body: { type: 'client-response', rpcId: 'question-rpc', result },
}]);
controller.abort();
await watching;
assert.equal(socket.readyState, 3);
});
test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',