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

File diff suppressed because one or more lines are too long

View file

@ -118,6 +118,13 @@ function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function orderedHistoryEvents(history) {
return (Array.isArray(history?.events) ? history.events : [])
.map((entry) => entry?.event ?? entry)
.filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
.sort((left, right) => left.seq - right.seq);
}
function senderOpenId(event) {
return nonEmptyString(event?.sender?.sender_id?.open_id)
?? nonEmptyString(event?.sender?.sender_id?.user_id);
@ -254,11 +261,10 @@ export class FeishuHarnessBridge {
#cardKeys = new Map();
/** The global event-mux watcher (one per bridge). */
#eventWatcher = null;
#eventWatchSignal = null;
/** Turn dedup: `sessionId:turnId` already processed. */
#handledTurns = new Set();
/** Session titles for completion cards (short in-memory cache). */
#sessionTitleCache = new Map();
/** Serializes live completions and reconnect compensation. */
#eventTail = Promise.resolve();
/** Earliest completion that still needs delivery for each watch. */
#failedWatchSeqs = new Map();
constructor({
client,
@ -314,9 +320,8 @@ export class FeishuHarnessBridge {
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
this.#signal = signal;
ensureStatus(this.#status);
// Persisted watches (and bound-session push targets) must resume at
// runtime start, not on the first message. Guarded: harnesses without
// the mux watcher (tests, older hosts) simply skip this.
// Persisted watches must resume at runtime start, not on the first
// message. Older hosts without the mux watcher simply skip this.
if (typeof this.#harness?.watchHarnessEvents === 'function') {
queueMicrotask(() => this.#ensureEventWatcher());
}
@ -544,6 +549,7 @@ export class FeishuHarnessBridge {
)),
...this.#interactionTasks,
...this.#commandTasks,
this.#eventTail,
]);
}
@ -592,8 +598,6 @@ export class FeishuHarnessBridge {
const text = message.content;
const hasImages = hasInboundImages(message);
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
// Keep the persistent delivery target fresh for bound-session pushes.
this.#rememberChatTarget(key, event.message.chat_id);
if (!text && !hasImages) {
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
return;
@ -1279,24 +1283,35 @@ export class FeishuHarnessBridge {
#ensureEventWatcher() {
if (this.#eventWatcher) return;
if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
this.#eventWatchSignal = new AbortController();
if (this.#signal?.aborted) return;
const signal = this.#signal ?? new AbortController().signal;
try {
this.#eventWatcher = this.#harness.watchHarnessEvents({
signal: this.#eventWatchSignal.signal,
signal,
onSessionEvent: (payload) => this.#onHarnessEvent(payload),
onReconnect: () => void this.#compensateMissedEvents(),
onReconnect: () => {
void this.#queueEventTask(() => this.#compensateMissedEvents());
},
});
Promise.resolve(this.#eventWatcher).catch((error) => {
if (!signal.aborted) {
this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
}
});
this.#eventWatcher?.catch?.(() => undefined);
} catch (error) {
this.#eventWatcher = null;
this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
}
}
/** Persist the chat delivery target for a conversation (bound pushes). */
#rememberChatTarget(key, chatId) {
if (typeof this.#state?.setChatTarget !== 'function' || !chatId) return;
this.#state.setChatTarget(key, chatId).catch(() => undefined);
#queueEventTask(task) {
const next = this.#eventTail.then(task, task).catch((error) => {
if (!this.#signal?.aborted) {
this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
}
});
this.#eventTail = next;
return next;
}
/**
@ -1337,6 +1352,17 @@ export class FeishuHarnessBridge {
return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
}
async #latestSessionSeq(sessionId) {
if (typeof this.#harness?.rpc !== 'function') return null;
const history = await this.#harness.rpc(
'session.history',
{ sessionId, maxMessages: 20 },
30_000,
{ signal: this.#signal },
);
return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
}
async #runWatch(key, chatId, target) {
this.#ensureEventWatcher();
if (typeof this.#state?.setWatch !== 'function') {
@ -1355,18 +1381,23 @@ export class FeishuHarnessBridge {
return;
}
const existing = this.#state.watchEntries?.(key) ?? [];
if (!existing.some((entry) => entry.sessionId === resolved.sessionId) && existing.length >= MAX_WATCHES_PER_KEY) {
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
return;
}
try {
const lastSeq = typeof existingEntry?.lastSeq === 'number'
? existingEntry.lastSeq
: await this.#latestSessionSeq(resolved.sessionId);
await this.#state.setWatch(key, {
sessionId: resolved.sessionId,
title: resolved.title,
chatId,
lastSeq: null,
lastSeq,
});
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
} catch (error) {
await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
}
@ -1384,6 +1415,7 @@ export class FeishuHarnessBridge {
}
try {
await this.#state.removeWatch(key, entry.sessionId);
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
} catch (error) {
await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
@ -1396,111 +1428,103 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, watchListCard(entries), { key });
}
/**
* A global mux session event. Completion pushes fire on turn/end, with
* dedup on `sessionId:turnId` and a seq watermark persisted per watch so
* reconnect compensation can replay only what was missed.
*/
/** Queue live turn completions behind any reconnect compensation. */
#onHarnessEvent({ sessionId, event }) {
if (!sessionId || !event || typeof event !== 'object' || event.type !== 'turn/end') return;
const turnId = nonEmptyString(event.data?.turn)
?? nonEmptyString(event.data?.turnId)
?? String(event.seq ?? '');
const dedupKey = `${sessionId}:${turnId}`;
if (this.#handledTurns.has(dedupKey)) return;
this.#handledTurns.add(dedupKey);
if (this.#handledTurns.size > 2000) {
const oldest = this.#handledTurns.values().next().value;
if (oldest !== undefined) this.#handledTurns.delete(oldest);
}
if (typeof event.seq === 'number' && typeof this.#state?.setWatch === 'function') {
for (const key of (this.#state.keysWatching?.(sessionId) ?? [])) {
const entry = this.#state.watchEntry?.(key, sessionId);
if (entry && (typeof entry.lastSeq !== 'number' || entry.lastSeq < event.seq)) {
this.#state.setWatch(key, { ...entry, lastSeq: event.seq }).catch(() => undefined);
}
}
}
void this.#sendCompletion(sessionId, event);
if (this.#signal?.aborted
|| !sessionId
|| !event
|| typeof event !== 'object'
|| event.type !== 'turn/end'
|| !Number.isFinite(event.seq)) return;
void this.#queueEventTask(async () => {
const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
.some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
if (hasFailedDelivery) await this.#compensateSession(sessionId);
await this.#deliverCompletion(sessionId, event);
});
}
async #sendCompletion(sessionId, event) {
async #deliverCompletion(sessionId, event) {
if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
const targets = new Set();
if (typeof this.#state?.keysWatching === 'function') {
for (const key of this.#state.keysWatching(sessionId)) {
const entry = this.#state.watchEntry?.(key, sessionId);
if (entry?.chatId) targets.add(entry.chatId);
for (const key of this.#state.keysWatching(sessionId)) {
if (this.#signal?.aborted) return;
const entry = this.#state.watchEntry?.(key, sessionId);
const deliveryKey = `${key}\0${sessionId}`;
let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
if (typeof failedSeq === 'number'
&& typeof entry?.lastSeq === 'number'
&& entry.lastSeq >= failedSeq) {
this.#failedWatchSeqs.delete(deliveryKey);
failedSeq = undefined;
}
}
if (typeof this.#state?.sessionKeysFor === 'function') {
for (const key of this.#state.sessionKeysFor(sessionId)) {
const chatId = typeof this.#state?.chatTargetFor === 'function'
? this.#state.chatTargetFor(key)
: null;
if (chatId) targets.add(chatId);
}
}
if (targets.size === 0) return;
const title = await this.#sessionTitleFor(sessionId);
for (const chatId of targets) {
if (!entry?.chatId
|| (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
|| (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
try {
await this.#sendCard(chatId, completionCard(sessionId, title, reason));
await this.#sendCard(
entry.chatId,
completionCard(sessionId, entry.title, reason),
{ key },
);
const current = this.#state.watchEntry?.(key, sessionId);
if (!current
|| current.chatId !== entry.chatId
|| (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
} catch (error) {
this.#failedWatchSeqs.set(
deliveryKey,
typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
);
this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
}
}
}
async #sessionTitleFor(sessionId) {
if (this.#sessionTitleCache.has(sessionId)) return this.#sessionTitleCache.get(sessionId);
let title = '暂无标题';
async #compensateSession(sessionId) {
if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
try {
const currentPath = typeof this.#harness?.currentWorkspace === 'function'
? this.#harness.currentWorkspace()
: null;
if (currentPath && typeof this.#harness?.listWorkspaceSessions === 'function') {
const listed = await this.#harness.listWorkspaceSessions(currentPath);
const session = (listed?.sessions ?? []).find((candidate) => candidate.sessionId === sessionId);
if (session) title = String(session.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
const history = await this.#harness.rpc(
'session.history',
{ sessionId, maxMessages: 20 },
30_000,
{ signal: this.#signal },
);
const events = orderedHistoryEvents(history);
const latestSeq = events.at(-1)?.seq ?? -1;
const keys = typeof this.#state?.keysWatching === 'function'
? this.#state.keysWatching(sessionId)
: [];
// Watches created by older versions have no baseline. Establish one
// without replaying completions that predate the watch.
for (const key of keys) {
const entry = this.#state.watchEntry?.(key, sessionId);
if (entry && typeof entry.lastSeq !== 'number') {
await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
}
}
for (const event of events) {
if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
}
} catch (error) {
if (!this.#signal?.aborted) {
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
}
} catch {
// Best-effort title; the card falls back to the id.
}
this.#sessionTitleCache.set(sessionId, title);
return title;
}
/**
* Replay turn/end events missed while the mux was disconnected. Reads
* each watched session's recent history and feeds unseen events through
* the normal handler, whose `sessionId:turnId` dedup makes the replay
* overlap-safe against live frames.
*/
/** Replay recent turn completions missed while the mux was disconnected. */
async #compensateMissedEvents() {
if (typeof this.#harness?.rpc !== 'function') return;
const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
? this.#state.watchedSessionIds()
: [];
for (const sessionId of sessionIds) {
try {
const history = await this.#harness.rpc('session.history', { sessionId, maxMessages: 20 });
const events = history?.events ?? [];
for (const event of events) {
if (event?.type !== 'turn/end' || typeof event.seq !== 'number') continue;
const keys = typeof this.#state?.keysWatching === 'function'
? this.#state.keysWatching(sessionId)
: [];
const allSeen = keys.length > 0 && keys.every((key) => {
const entry = this.#state.watchEntry?.(key, sessionId);
return entry && typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq;
});
if (allSeen) continue;
this.#onHarnessEvent({ sessionId, event });
}
} catch (error) {
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
}
if (this.#signal?.aborted) return;
await this.#compensateSession(sessionId);
}
}

View file

@ -211,7 +211,15 @@ export function watchListCard(entries) {
* turn-end kind (completed / stopped / aborted).
*/
export function completionCard(sessionId, title, reason) {
const reasonText = reason === 'stopped' ? '已停止' : reason === 'aborted' ? '已中止' : '已完成';
const reasonText = reason === 'completed'
? '已完成'
: reason === 'stopped'
? '已停止'
: reason === 'aborted'
? '已中止'
: reason === 'cancelled'
? '已取消'
: '已结束';
return cardWith('✅ 任务完成', [
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },

View file

@ -6,7 +6,6 @@ const EMPTY_STATE = Object.freeze({
sessions: {},
seenMessageIds: [],
watches: {},
chatTargets: {},
includeArchivedSessions: false,
});
@ -38,7 +37,6 @@ export class StateStore {
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
chatTargets: parsed.chatTargets && typeof parsed.chatTargets === 'object' ? parsed.chatTargets : {},
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
? parsed.includeArchivedSessions
: false,
@ -138,27 +136,7 @@ export class StateStore {
return [...ids];
}
/** Conversation keys whose bound session is the given id. */
sessionKeysFor(sessionId) {
return Object.entries(this.#state.sessions)
.filter(([, bound]) => bound === sessionId)
.map(([key]) => key);
}
// ── Persistent chat delivery targets for bound sessions ─────────────────
chatTargetFor(key) {
const target = this.#state.chatTargets[key];
return typeof target === 'string' && target.length > 0 ? target : null;
}
async setChatTarget(key, chatId) {
if (this.#state.chatTargets[key] === chatId) return;
this.#state.chatTargets[key] = chatId;
await this.#persist();
}
// ── Session-list archived policy (per bot) ───────────────────────────────
// ── Session-list archived policy (per bot) ───────────────────
includesArchivedSessions() {
return this.#state.includeArchivedSessions === true;

View file

@ -1231,75 +1231,115 @@ export class HarnessClient {
* provided) fires after every (re)connection so callers can compensate
* for events missed while offline.
*/
watchHarnessEvents({ signal, onSessionEvent, onReconnect }) {
async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
if (typeof onSessionEvent !== 'function') {
return Promise.reject(new TypeError('watchHarnessEvents requires onSessionEvent'));
throw new TypeError('watchHarnessEvents requires onSessionEvent');
}
if (!signal || typeof signal.addEventListener !== 'function') {
throw new TypeError('watchHarnessEvents requires an AbortSignal');
}
if (onReconnect !== undefined && typeof onReconnect !== 'function') {
throw new TypeError('onReconnect must be a function');
}
const url = new URL('/api/events.mux', this.#baseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
let stopped = false;
const connect = () => {
if (stopped || signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const socket = this.#createWebSocket(url.toString());
const settle = () => {
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('message', handleMessage);
socket.removeEventListener('close', handleClose);
socket.removeEventListener('error', handleError);
signal.removeEventListener('abort', handleAbort);
resolve();
};
const handleOpen = () => {
try {
onReconnect?.();
} catch (error) {
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
}
};
const handleMessage = (event) => {
try {
if (typeof event.data !== 'string') return;
const envelope = JSON.parse(event.data);
const payload = envelope?.payload;
if (envelope?.type !== 'server-request' || !payload || typeof payload !== 'object') return;
if (payload.type !== 'session/event') return;
if (typeof payload.sessionId !== 'string' || !payload.event || typeof payload.event !== 'object') return;
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
}
};
const handleClose = () => {
settle();
if (!stopped && !signal.aborted) {
setTimeout(() => { void connect(); }, 2000);
}
};
const handleError = () => {
try {
socket.close();
} catch {
// The close event drives reconnection; nothing to do here.
}
};
const handleAbort = () => {
stopped = true;
try {
socket.close();
} catch {
// Already closed.
}
};
socket.addEventListener('open', handleOpen);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose, { once: true });
socket.addEventListener('error', handleError, { once: true });
signal.addEventListener('abort', handleAbort, { once: true });
if (signal.aborted) handleAbort();
});
};
return connect();
while (!signal.aborted) {
try {
await this.#watchHarnessEventSocket(url.toString(), {
signal,
onSessionEvent,
onReconnect,
});
} catch (error) {
if (signal.aborted) return;
console.warn(`[${this.#logPrefix}] Harness event mux disconnected:`, error.message);
}
if (signal.aborted) return;
try {
await sleep(this.#interactionReconnectDelayMs, signal);
} catch {
if (signal.aborted) return;
throw new Error('Harness event mux reconnect wait failed');
}
}
}
#watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
return new Promise((resolve, reject) => {
let socket;
try {
socket = this.#createWebSocket(url);
} catch (error) {
reject(error);
return;
}
let opened = false;
let finished = false;
const close = () => {
try {
socket.close();
} catch {
// Already closed.
}
};
const finish = (error) => {
if (finished) return;
finished = true;
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('message', handleMessage);
socket.removeEventListener('close', handleClose);
socket.removeEventListener('error', handleError);
signal.removeEventListener('abort', handleAbort);
if (error) reject(error);
else resolve();
};
const handleOpen = () => {
opened = true;
try {
onReconnect?.();
} catch (error) {
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
}
};
const handleMessage = (event) => {
try {
if (typeof event.data !== 'string') return;
const envelope = JSON.parse(event.data);
const payload = envelope?.payload;
if (envelope?.type !== 'server-request'
|| !payload
|| typeof payload !== 'object'
|| envelope.method !== payload.type
|| payload.type !== 'session/event'
|| typeof payload.sessionId !== 'string'
|| !payload.event
|| typeof payload.event !== 'object') return;
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
}
};
const handleClose = () => finish(opened ? null : new Error(
'Harness event mux WebSocket closed before opening',
));
const handleError = () => {
finish(new Error(opened
? 'Harness event mux WebSocket failed'
: 'Harness event mux WebSocket failed before opening'));
close();
};
const handleAbort = () => {
close();
finish();
};
socket.addEventListener('open', handleOpen);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose, { once: true });
socket.addEventListener('error', handleError, { once: true });
signal.addEventListener('abort', handleAbort, { once: true });
if (signal.aborted) handleAbort();
});
}
stopManagedProcess() {

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