fix(feishu): make watch completion delivery reliable

This commit is contained in:
xmanrui 2026-08-21 16:26:58 +08:00
parent 4e325c62e0
commit a199c65356
8 changed files with 634 additions and 328 deletions

View file

@ -2415,6 +2415,7 @@ 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,
@ -2422,20 +2423,25 @@ function watchHarness({ sessionsByWorkspace = { 'C:/work': [] }, current = 'C:/w
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: history } : null),
watchHarnessEvents: ({ onSessionEvent, onReconnect }) => {
listeners.push({ onSessionEvent, onReconnect });
return Promise.resolve();
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 store = new StateStore(join(tmpdir(), `dsh-im-watch-test-${Math.random().toString(36).slice(2)}.json`));
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 { store, state: store };
return { path, store, state: store };
}
test('/watch resolves read-only: no binding, no workspace switch', async () => {
@ -2495,15 +2501,16 @@ test('/watch finds a session in another workspace without switching', async () =
});
test('persisted watches resume the event watcher at runtime start', async () => {
const { state } = await watchStoreFixture();
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,
state: reloadedState,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
@ -2514,12 +2521,11 @@ test('persisted watches resume the event watcher at runtime start', async () =>
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: null });
await state.setWatch('p2p:ou_owner', { sessionId: 'watched-session', title: 'Watched', chatId: 'oc_chat', lastSeq: 9 });
const harness = watchHarness({
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'watched-session', title: 'Watched' }] },
history: [
{ type: 'turn/end', seq: 10, data: { turn: 't1', reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 11, data: { turn: 't2', reason: { kind: 'completed' } } },
{ 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 = [];
@ -2536,22 +2542,194 @@ test('reconnect compensation replays missed turn/end and dedups duplicates', asy
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(harness._listeners.length, 1);
// Live event: t1 arrives before any replay.
harness._listeners[0].onSessionEvent({ sessionId: 'watched-session', event: { type: 'turn/end', seq: 10, data: { turn: 't1', reason: { kind: 'completed' } } } });
// Real history wraps events and may return them out of order.
harness._listeners[0].onReconnect();
await bridge.waitForIdle();
assert.equal(cards.length, 1, 'one completion for the live event');
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: history replays t1 (dedup) and t2 (new).
await harness._listeners[0].onReconnect();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(cards.length, 2, 'exactly one new completion from compensation (t1 deduped)');
assert.match(JSON.stringify(cards[1]), /watched-session/);
// 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('/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 work = join(tmpdir(), 'dsh-im-archived-test-work');
mkdirSync(work, { recursive: true });
const workRaw = join(tmpdir(), 'dsh-im-archived-test-work');
mkdirSync(workRaw, { recursive: true });
const work = realpathSync(workRaw);
const harness = watchHarness({
current: work,
sessionsByWorkspace: {

View file

@ -16,13 +16,13 @@ function buttons(value, result = []) {
return result;
}
test('menu exposes repair as number-only text instead of a callback button', () => {
test('menu appends watchlist and keeps repair number-only', () => {
const card = JSON.parse(menuCard());
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
const actions = buttons(card).flatMap((button) => (
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
));
assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help']);
assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help', 'watchlist']);
assert.equal(actions.includes('repair'), false);
});

View file

@ -159,6 +159,84 @@ test('interaction watcher uses the real Harness wire protocol and leaves approva
assert.equal(socket.readyState, 3);
});
test('global event watcher reconnects without resolving until abort', async () => {
const sockets = [];
const socketUrls = [];
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080/base',
workspace: '/tmp/dsh-feishu-workspace',
interactionReconnectDelayMs: 0,
createWebSocket: (url) => {
const socket = new FakeSocket();
sockets.push(socket);
socketUrls.push(url);
queueMicrotask(() => socket.open());
return socket;
},
});
const controller = new AbortController();
const events = [];
let reconnects = 0;
let settled = false;
const watching = client.watchHarnessEvents({
signal: controller.signal,
onReconnect: () => { reconnects += 1; },
onSessionEvent: (payload) => events.push(payload),
});
watching.finally(() => { settled = true; });
await eventually(() => reconnects === 1);
sockets[0].frame({
type: 'server-request',
rpcId: 'event-one',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'session-one',
event: { type: 'turn/end', seq: 1 },
},
});
sockets[0].frame({
type: 'server-request',
rpcId: 'invalid-method',
method: 'different/method',
payload: {
type: 'session/event',
sessionId: 'ignored',
event: { type: 'turn/end', seq: 2 },
},
});
await eventually(() => events.length === 1);
sockets[0].close();
await eventually(() => reconnects === 2);
assert.equal(settled, false, 'a dropped socket must not complete the watcher');
sockets[1].frame({
type: 'server-request',
rpcId: 'event-two',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'session-two',
event: { type: 'turn/end', seq: 3 },
},
});
await eventually(() => events.length === 2);
assert.deepEqual(socketUrls, [
'ws://127.0.0.1:3080/api/events.mux',
'ws://127.0.0.1:3080/api/events.mux',
]);
assert.deepEqual(events.map(({ sessionId, event }) => [sessionId, event.seq]), [
['session-one', 1],
['session-two', 3],
]);
controller.abort();
await watching;
assert.equal(sockets[1].readyState, 3);
assert.equal(settled, true);
});
test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',