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: {