mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
fix(feishu): make watch completion delivery reliable
This commit is contained in:
parent
4e325c62e0
commit
a199c65356
8 changed files with 634 additions and 328 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue