mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
fix(feishu): stabilize post-merge card workflows
This commit is contained in:
parent
9d1dc71794
commit
bd9c58cf9e
19 changed files with 3372 additions and 456 deletions
355
lib/index.js
355
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
|
|||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
import { larkSdkHandshakePatch } from './lark-sdk-handshake-patch.mjs';
|
||||
|
||||
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(sourceDirectory, '../..');
|
||||
const outputPath = resolve(packageRoot, 'lib/index.js');
|
||||
|
|
@ -25,6 +27,7 @@ await build({
|
|||
target: ['node22'],
|
||||
mainFields: ['module', 'main'],
|
||||
external,
|
||||
plugins: [larkSdkHandshakePatch],
|
||||
outfile: outputPath,
|
||||
banner: {
|
||||
js: [
|
||||
|
|
|
|||
181
plugin-src/host/lark-sdk-handshake-patch.mjs
Normal file
181
plugin-src/host/lark-sdk-handshake-patch.mjs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const PATCHES = [
|
||||
{
|
||||
label: 'WSClient pending-socket state',
|
||||
before: ` this.wsConfig = new WSConfig();
|
||||
this.reconnectGeneration = 0;
|
||||
this.isConnecting = false;`,
|
||||
after: ` this.wsConfig = new WSConfig();
|
||||
this.reconnectGeneration = 0;
|
||||
this.pendingWsInstance = null;
|
||||
this.isConnecting = false;`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient pending-socket registration',
|
||||
before: ` if (!wsInstance) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {`,
|
||||
after: ` if (!wsInstance) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
this.pendingWsInstance = wsInstance;
|
||||
return new Promise((resolve) => {`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient pending-socket settlement',
|
||||
before: ` if (timer)
|
||||
clearTimeout(timer);
|
||||
resolve(ok);`,
|
||||
after: ` if (timer)
|
||||
clearTimeout(timer);
|
||||
if (this.pendingWsInstance === wsInstance)
|
||||
this.pendingWsInstance = null;
|
||||
resolve(ok);`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient handshake-timeout listener cleanup',
|
||||
before: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
|
||||
wsInstance.removeAllListeners();`,
|
||||
after: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
|
||||
wsInstance.removeAllListeners('open');`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient reconnect generation fences',
|
||||
before: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
|
||||
this.reconnectInfo.lastConnectTime = Date.now();
|
||||
const pullResult = yield this.pullConnectConfig();
|
||||
if (!pullResult.ok)
|
||||
return pullResult;
|
||||
const connected = yield this.connect();
|
||||
if (!connected)
|
||||
return { ok: false, retryable: true };
|
||||
this.communicate();
|
||||
return { ok: true };
|
||||
});`,
|
||||
after: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
|
||||
if (currentGeneration !== this.reconnectGeneration)
|
||||
return { ok: false, retryable: false, cancelled: true };
|
||||
this.reconnectInfo.lastConnectTime = Date.now();
|
||||
const pullResult = yield this.pullConnectConfig();
|
||||
if (currentGeneration !== this.reconnectGeneration)
|
||||
return { ok: false, retryable: false, cancelled: true };
|
||||
if (!pullResult.ok)
|
||||
return pullResult;
|
||||
const connected = yield this.connect();
|
||||
if (currentGeneration !== this.reconnectGeneration)
|
||||
return { ok: false, retryable: false, cancelled: true };
|
||||
if (!connected)
|
||||
return { ok: false, retryable: true };
|
||||
this.communicate();
|
||||
return { ok: true };
|
||||
});`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient initial-connect cancellation fence',
|
||||
before: ` try {
|
||||
result = yield tryConnect();
|
||||
}
|
||||
finally {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
if (result.ok) {`,
|
||||
after: ` try {
|
||||
result = yield tryConnect();
|
||||
}
|
||||
finally {
|
||||
if (currentGeneration === this.reconnectGeneration) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
if (currentGeneration !== this.reconnectGeneration || result.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (result.ok) {`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient pending-socket close',
|
||||
before: ` const wsInstance = this.wsConfig.getWSInstance();
|
||||
if (wsInstance) {`,
|
||||
after: ` const pendingWsInstance = this.pendingWsInstance;
|
||||
if (pendingWsInstance) {
|
||||
this.pendingWsInstance = null;
|
||||
pendingWsInstance.removeAllListeners('open');
|
||||
try {
|
||||
if (force) {
|
||||
pendingWsInstance.terminate();
|
||||
}
|
||||
else {
|
||||
pendingWsInstance.close();
|
||||
}
|
||||
}
|
||||
catch ( /* best effort */_a) { /* best effort */ }
|
||||
}
|
||||
const wsInstance = this.wsConfig.getWSInstance();
|
||||
if (wsInstance) {`,
|
||||
},
|
||||
{
|
||||
label: 'WSClient idempotent start guard',
|
||||
before: ` const { eventDispatcher } = params;
|
||||
if (!eventDispatcher) {
|
||||
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
|
||||
return;
|
||||
}
|
||||
// Clear any terminal-error state left over from a previous session so`,
|
||||
after: ` const { eventDispatcher } = params;
|
||||
if (!eventDispatcher) {
|
||||
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
|
||||
return;
|
||||
}
|
||||
const liveWsInstance = this.wsConfig.getWSInstance();
|
||||
if (this.terminalError) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
if (this.isConnecting ||
|
||||
(liveWsInstance && liveWsInstance.readyState !== WebSocket.CLOSED)) {
|
||||
this.logger.debug('[ws]', 'start ignored because client is already connecting or connected');
|
||||
return;
|
||||
}
|
||||
// Clear any terminal-error state left over from a previous session so`,
|
||||
},
|
||||
];
|
||||
|
||||
function replaceExactlyOnce(source, patch, sourcePath) {
|
||||
const matches = source.split(patch.before).length - 1;
|
||||
if (matches !== 1) {
|
||||
throw new Error(
|
||||
`${sourcePath}: expected exactly one reviewed ${patch.label} marker, found ${matches}`,
|
||||
);
|
||||
}
|
||||
return source.replace(patch.before, patch.after);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch @larksuiteoapi/node-sdk 1.73.0's WSClient lifecycle.
|
||||
*
|
||||
* The vendor client cannot normally close a socket until its WebSocket
|
||||
* handshake has opened. Its timeout also removes the socket's `error`
|
||||
* listener before terminating it, and the initial connection path can resume
|
||||
* after close() and start a zombie reconnect loop. Track and safely terminate
|
||||
* the pending socket, retain its error listener, and fence every async initial
|
||||
* connection stage with the reconnect generation already maintained by the
|
||||
* SDK. Every reviewed source fragment must match exactly once so an SDK source
|
||||
* change fails the build instead of silently losing the compatibility fix.
|
||||
*/
|
||||
export function patchLarkSdkHandshakeSource(source, sourcePath = 'Lark SDK') {
|
||||
return PATCHES.reduce(
|
||||
(patched, patch) => replaceExactlyOnce(patched, patch, sourcePath),
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
export const larkSdkHandshakePatch = {
|
||||
name: 'dsh-lark-sdk-websocket-lifecycle-fix',
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /@larksuiteoapi[\\/]node-sdk[\\/](es|lib)[\\/]index\.js$/ }, async ({ path }) => ({
|
||||
contents: patchLarkSdkHandshakeSource(await readFile(path, 'utf8'), path),
|
||||
loader: 'js',
|
||||
}));
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -831,6 +831,9 @@ export function customSteerCard() {
|
|||
},
|
||||
{
|
||||
tag: 'button',
|
||||
// Card 2.0 form controls need stable names so the client can
|
||||
// construct a submit action (and expose action.name) reliably.
|
||||
name: 'steer_submit',
|
||||
text: { tag: 'plain_text', content: t('提交') },
|
||||
type: 'primary',
|
||||
width: 'fill',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.a
|
|||
const CALLBACK_PROBE_TIMEOUT_NOTICE = '⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。';
|
||||
const CALLBACK_PROBE_SEND_FAILURE_NOTICE = '⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。';
|
||||
const CALLBACK_PROBE_ABORT_NOTICE = '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。';
|
||||
const REUSABLE_WS_STATES = new Set(['connected', 'connecting', 'reconnecting']);
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -24,6 +25,15 @@ function strictCardOperatorOpenId(event) {
|
|||
?? nonEmptyString(event?.operator?.operator_id?.open_id);
|
||||
}
|
||||
|
||||
function websocketState(wsClient, fallback) {
|
||||
try {
|
||||
const state = wsClient?.getConnectionStatus?.()?.state;
|
||||
return typeof state === 'string' && state ? state : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function probeError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
|
|
@ -104,6 +114,7 @@ export class FeishuRuntime {
|
|||
#bridge = null;
|
||||
#wsClient = null;
|
||||
#starting = null;
|
||||
#stopping = null;
|
||||
#abortController = null;
|
||||
#pendingCardActionProbes = new Map();
|
||||
#status;
|
||||
|
|
@ -170,25 +181,59 @@ export class FeishuRuntime {
|
|||
}
|
||||
|
||||
async start() {
|
||||
if (this.#wsClient && this.#status.ready) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
while (true) {
|
||||
while (this.#stopping) await this.#stopping;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
||||
this.#starting = this.#start().finally(() => {
|
||||
this.#starting = null;
|
||||
const wsClient = this.#wsClient;
|
||||
if (wsClient) {
|
||||
const state = websocketState(wsClient, this.#status.feishuLongConnectionState);
|
||||
if (REUSABLE_WS_STATES.has(state)) return this.status;
|
||||
|
||||
await this.stop({ preserveError: state === 'failed' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// A partial/failed attempt may have created resources before its
|
||||
// WSClient became observable. Drain them before assigning a new attempt.
|
||||
if (this.#client || this.#bridge || this.#abortController) {
|
||||
await this.stop({
|
||||
preserveError: this.#status.feishuLongConnectionState === 'failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
let starting;
|
||||
starting = this.#start().finally(() => {
|
||||
if (this.#starting === starting) this.#starting = null;
|
||||
});
|
||||
return this.#starting;
|
||||
this.#starting = starting;
|
||||
return starting;
|
||||
}
|
||||
|
||||
async #start() {
|
||||
const abortController = new AbortController();
|
||||
this.#abortController = abortController;
|
||||
const { signal } = abortController;
|
||||
const abortError = () => (
|
||||
signal.reason ?? new DOMException('Feishu runtime stopped', 'AbortError')
|
||||
);
|
||||
const isCurrentStart = () => (
|
||||
!signal.aborted && this.#abortController === abortController
|
||||
);
|
||||
const assertCurrentStart = () => {
|
||||
if (!isCurrentStart()) throw abortError();
|
||||
};
|
||||
this.#status.startedAt = new Date().toISOString();
|
||||
this.#status.feishuLongConnectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal });
|
||||
assertCurrentStart();
|
||||
this.#status.harnessReachable = true;
|
||||
|
||||
const sdkDomain = this.#domain === 'lark'
|
||||
|
|
@ -204,13 +249,14 @@ export class FeishuRuntime {
|
|||
this.#requestTimeoutMs,
|
||||
);
|
||||
if (httpInstance) larkConfig.httpInstance = httpInstance;
|
||||
this.#client = new this.#lark.Client(larkConfig);
|
||||
const client = new this.#lark.Client(larkConfig);
|
||||
this.#client = client;
|
||||
const channel = new VerifiedFeishuChannel({
|
||||
client: this.#client,
|
||||
client,
|
||||
initialText: t('已连接 DeepSeek Harness,正在思考…'),
|
||||
});
|
||||
this.#bridge = new FeishuHarnessBridge({
|
||||
client: this.#client,
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
@ -226,22 +272,22 @@ export class FeishuRuntime {
|
|||
signal,
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#bridge = bridge;
|
||||
|
||||
const dispatcher = new this.#lark.EventDispatcher({}).register({
|
||||
'im.message.receive_v1': (event) => {
|
||||
this.#bridge.accept(event);
|
||||
return {};
|
||||
if (isCurrentStart()) void bridge.accept(event);
|
||||
},
|
||||
'im.message.reaction.created_v1': () => ({}),
|
||||
'im.message.reaction.deleted_v1': () => ({}),
|
||||
'im.message.reaction.created_v1': () => undefined,
|
||||
'im.message.reaction.deleted_v1': () => undefined,
|
||||
// Interactive-card button callbacks (only delivered when the app
|
||||
// subscribes card.action.trigger; the number-reply fallback covers
|
||||
// apps that do not).
|
||||
'card.action.trigger': (event) => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.cardActionsReceived += 1;
|
||||
this.#status.lastCardActionAt = new Date().toISOString();
|
||||
if (!this.#consumeCardActionProbe(event)) this.#bridge.onCardAction(event);
|
||||
return {};
|
||||
if (!this.#consumeCardActionProbe(event)) void bridge.onCardAction(event);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -249,37 +295,49 @@ export class FeishuRuntime {
|
|||
let settleError;
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
const onAbort = () => {
|
||||
settleError(abortError());
|
||||
};
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`));
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle(
|
||||
reject,
|
||||
new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`),
|
||||
);
|
||||
}, this.#connectTimeoutMs);
|
||||
settleReady = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
settle(resolve);
|
||||
};
|
||||
settleError = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
settle(reject, error);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
// The SDK constructor can throw before Promise.all attaches below.
|
||||
// Keep the abort-driven rejection observed in that path as well.
|
||||
void ready.catch(() => undefined);
|
||||
|
||||
this.#wsClient = new this.#lark.WSClient({
|
||||
const wsClient = new this.#lark.WSClient({
|
||||
...larkConfig,
|
||||
...(this.#wsAgent ? { agent: this.#wsAgent } : {}),
|
||||
loggerLevel: this.#lark.LoggerLevel.info,
|
||||
handshakeTimeoutMs: 15000,
|
||||
handshakeTimeoutMs: this.#connectTimeoutMs,
|
||||
onReady: () => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'connected';
|
||||
this.#status.ready = true;
|
||||
this.#status.lastError = null;
|
||||
settleReady();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'failed';
|
||||
this.#status.ready = false;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
|
|
@ -287,25 +345,35 @@ export class FeishuRuntime {
|
|||
settleError(error);
|
||||
},
|
||||
onReconnecting: () => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'reconnecting';
|
||||
this.#status.ready = false;
|
||||
},
|
||||
onReconnected: () => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'connected';
|
||||
this.#status.ready = true;
|
||||
this.#status.lastError = null;
|
||||
},
|
||||
});
|
||||
await this.#wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
|
||||
settleError(error);
|
||||
});
|
||||
await ready;
|
||||
this.#wsClient = wsClient;
|
||||
const wsStarted = Promise.resolve()
|
||||
.then(() => wsClient.start({ eventDispatcher: dispatcher }))
|
||||
.catch((error) => {
|
||||
settleError(error);
|
||||
throw error;
|
||||
});
|
||||
await Promise.all([wsStarted, ready]);
|
||||
assertCurrentStart();
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
// stop() owns the terminal idle state for an explicitly aborted start.
|
||||
// In particular, do not let the rejected handshake waiter overwrite it.
|
||||
if (signal.aborted) throw error;
|
||||
this.#status.ready = false;
|
||||
this.#status.feishuLongConnectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.stop({ preserveError: true });
|
||||
await this.#cleanup({ preserveError: true, abortController });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -492,10 +560,30 @@ export class FeishuRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
async stop({ preserveError = false } = {}) {
|
||||
const error = preserveError ? this.#status.lastError : null;
|
||||
stop(options = {}) {
|
||||
if (this.#stopping) return this.#stopping;
|
||||
|
||||
let stopping;
|
||||
stopping = this.#stop(options).finally(() => {
|
||||
if (this.#stopping === stopping) this.#stopping = null;
|
||||
});
|
||||
this.#stopping = stopping;
|
||||
return stopping;
|
||||
}
|
||||
|
||||
async #stop({ preserveError = false } = {}) {
|
||||
const abortController = this.#abortController;
|
||||
this.#abortController = null;
|
||||
if (this.#abortController === abortController) this.#abortController = null;
|
||||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||||
|
||||
const starting = this.#starting;
|
||||
if (starting) await starting.catch(() => undefined);
|
||||
return this.#cleanup({ preserveError, abortController });
|
||||
}
|
||||
|
||||
async #cleanup({ preserveError = false, abortController } = {}) {
|
||||
const error = preserveError ? this.#status.lastError : null;
|
||||
if (this.#abortController === abortController) this.#abortController = null;
|
||||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||||
for (const probe of this.#pendingCardActionProbes.values()) {
|
||||
clearTimeout(probe.timeout);
|
||||
|
|
@ -508,14 +596,12 @@ export class FeishuRuntime {
|
|||
}
|
||||
this.#pendingCardActionProbes.clear();
|
||||
this.#status.ready = false;
|
||||
if (this.#wsClient) {
|
||||
this.#wsClient.close({ force: true });
|
||||
this.#wsClient = null;
|
||||
}
|
||||
if (this.#bridge) {
|
||||
await this.#bridge.waitForIdle();
|
||||
this.#bridge = null;
|
||||
}
|
||||
const wsClient = this.#wsClient;
|
||||
this.#wsClient = null;
|
||||
wsClient?.close({ force: true });
|
||||
const bridge = this.#bridge;
|
||||
this.#bridge = null;
|
||||
if (bridge) await bridge.waitForIdle();
|
||||
this.#client = null;
|
||||
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
||||
this.#status.lastError = error;
|
||||
|
|
|
|||
|
|
@ -223,6 +223,11 @@ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
|
|||
origin: summary?.origin === 'subagent' ? 'subagent' : null,
|
||||
summaryAvailable: summary !== undefined,
|
||||
};
|
||||
const lastSeq = summary?.projections?.asOfSeq;
|
||||
// This is the projection's durable lower bound, not necessarily the live
|
||||
// log tail for a cold session. Harness uses -1 as the legitimate bound
|
||||
// for a session with no projected events yet.
|
||||
if (Number.isSafeInteger(lastSeq) && lastSeq >= -1) session.lastSeq = lastSeq;
|
||||
const time = sessionTimeMs(summary);
|
||||
if (time !== null) session.time = time;
|
||||
return session;
|
||||
|
|
|
|||
|
|
@ -134,9 +134,20 @@ export default {
|
|||
'当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。':
|
||||
'The current task is still running. Stop it or wait for it to finish before starting a new session.',
|
||||
'请输入补充指令后再提交。': 'Enter an instruction before submitting.',
|
||||
'操作过于频繁,请稍后再试。': 'Too many card actions are pending. Please try again shortly.',
|
||||
'卡片操作失败,请稍后重试。': 'The card action failed. Please try again later.',
|
||||
'请先选择至少一个会话。': 'Select at least one session first.',
|
||||
'已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
|
||||
'已批量关注 {count} 个会话,另有 {failed} 个未成功。':
|
||||
'Now watching {count} sessions; {failed} could not be processed.',
|
||||
'已关注(或已达关注上限)。': 'Sessions are already watched, or the watch limit has been reached.',
|
||||
'已取消关注 {count} 个会话。': 'Stopped watching {count} sessions.',
|
||||
'已取消关注 {count} 个会话,另有 {failed} 个未成功。':
|
||||
'Stopped watching {count} sessions; {failed} could not be processed.',
|
||||
'所选会话均未处理成功,请稍后重试。':
|
||||
'None of the selected sessions could be processed. Please try again later.',
|
||||
'所选会话已在关注列表中。': 'The selected sessions are already being watched.',
|
||||
'所选会话已不在关注列表中。': 'The selected sessions are no longer in the watch list.',
|
||||
'未取消任何关注。': 'No watches were removed.',
|
||||
'当前没有绑定的会话,请先从会话列表选择。':
|
||||
'No session is currently bound. Select one from the session list first.',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const MODELS_COMMAND = /^\/models(?=$|\s)/i;
|
|||
const MODEL_USAGE = '用法:/model <序号> 或 /model <provider>/<model>';
|
||||
const MODELS_USAGE = '用法:/models(不带参数)';
|
||||
const SESSION_BINDING_CHANGED = 'session-binding-changed';
|
||||
const MODEL_SELECTION_MISMATCH = 'model-selection-mismatch';
|
||||
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
||||
|
||||
function commandResult(message) {
|
||||
|
|
@ -78,6 +79,32 @@ function modelId(provider, model) {
|
|||
return `${provider}/${model}`;
|
||||
}
|
||||
|
||||
function sameModel(left, right) {
|
||||
return left?.provider === right?.provider && left?.model === right?.model;
|
||||
}
|
||||
|
||||
function selectionMismatch(expected, actual, source) {
|
||||
const error = new Error(`Harness ${source} did not confirm the selected model`);
|
||||
error.code = MODEL_SELECTION_MISMATCH;
|
||||
error.expected = expected;
|
||||
error.actual = actual;
|
||||
error.source = source;
|
||||
return error;
|
||||
}
|
||||
|
||||
function sessionBindingChanged() {
|
||||
const error = new Error('Conversation binding changed during model selection');
|
||||
error.code = SESSION_BINDING_CHANGED;
|
||||
return error;
|
||||
}
|
||||
|
||||
function assertSessionBinding(state, key, expectedSessionId) {
|
||||
const currentSessionId = typeof state?.sessionFor === 'function'
|
||||
? state.sessionFor(key)
|
||||
: null;
|
||||
if (currentSessionId !== expectedSessionId) throw sessionBindingChanged();
|
||||
}
|
||||
|
||||
function matchingModel(catalog, requested) {
|
||||
for (const group of catalog.groups) {
|
||||
for (const model of group.models) {
|
||||
|
|
@ -181,6 +208,23 @@ function modelErrorMessage(error, action) {
|
|||
if (code === SESSION_BINDING_CHANGED) {
|
||||
return t('当前聊天绑定的会话已发生变化,请重试。');
|
||||
}
|
||||
if (code === MODEL_SELECTION_MISMATCH) {
|
||||
const expected = error?.expected;
|
||||
const actual = error?.actual;
|
||||
const lines = [t('模型切换失败,请稍后重试。')];
|
||||
if (expected?.provider && expected?.model) {
|
||||
lines.push('', `requested: ${safeDisplayText(modelId(expected.provider, expected.model))}`);
|
||||
}
|
||||
if (actual?.provider && actual?.model) {
|
||||
const label = error?.source === 'models.current'
|
||||
? t('当前模型:')
|
||||
: 'selectModel.selected:';
|
||||
lines.push(`${label} ${safeDisplayText(modelId(actual.provider, actual.model))}`);
|
||||
} else {
|
||||
lines.push(`${error?.source ?? 'Harness'}: unconfirmed`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (code === 'cancelled' || error?.name === 'AbortError') {
|
||||
return action === 'list' ? t('获取模型列表已取消。') : t('模型切换已取消。');
|
||||
}
|
||||
|
|
@ -230,6 +274,21 @@ async function sessionCatalog(session, options) {
|
|||
return normalizeCatalog(await session.models(options), { requireCurrent: true });
|
||||
}
|
||||
|
||||
async function selectAndVerifyModel(session, selection, options) {
|
||||
if (typeof session?.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
const selected = (await session.selectModel(selection, options))?.selected;
|
||||
if (!sameModel(selected, selection)) {
|
||||
throw selectionMismatch(selection, selected, 'selectModel.selected');
|
||||
}
|
||||
const current = (await sessionCatalog(session, options)).current;
|
||||
if (!sameModel(current, selection)) {
|
||||
throw selectionMismatch(selection, current, 'models.current');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isModelsCommand(command) {
|
||||
return MODELS_COMMAND.test(command);
|
||||
}
|
||||
|
|
@ -312,11 +371,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|||
].join('\n'));
|
||||
}
|
||||
|
||||
let applied;
|
||||
if (bound) {
|
||||
if (typeof bound.session.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
await bound.session.selectModel(selection, requestOptions);
|
||||
applied = await selectAndVerifyModel(bound.session, selection, requestOptions);
|
||||
assertSessionBinding(state, key, bound.sessionId);
|
||||
} else {
|
||||
if (typeof harness?.createSession !== 'function'
|
||||
|| typeof harness?.workspaceSession !== 'function'
|
||||
|
|
@ -329,15 +387,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|||
throw new TypeError('Harness returned an invalid session id');
|
||||
}
|
||||
const session = harness.workspaceSession(sessionId);
|
||||
if (!session || typeof session.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
await session.selectModel(selection, requestOptions);
|
||||
applied = await selectAndVerifyModel(session, selection, requestOptions);
|
||||
const currentSessionId = state.sessionFor(key);
|
||||
if (typeof currentSessionId === 'string' && currentSessionId) {
|
||||
const changed = new Error('Conversation binding changed during model selection');
|
||||
changed.code = SESSION_BINDING_CHANGED;
|
||||
throw changed;
|
||||
throw sessionBindingChanged();
|
||||
}
|
||||
if (await state.setSession(key, sessionId) === false) {
|
||||
const stale = new Error('Workspace changed while binding the new session');
|
||||
|
|
@ -348,7 +401,7 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|||
return commandResult(t(`模型已切换为:
|
||||
{model}
|
||||
|
||||
后续消息将使用该模型。`, { model: modelId(selection.provider, selection.model) }));
|
||||
后续消息将使用该模型。`, { model: modelId(applied.provider, applied.model) }));
|
||||
});
|
||||
} catch (error) {
|
||||
return commandResult(modelErrorMessage(error, 'select'));
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ async function selectedWorkspacePath(value) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function workspacePathSnapshot(harness) {
|
||||
const listed = await harness.listWorkspaces();
|
||||
export async function workspacePathSnapshot(harness, options = {}) {
|
||||
const listed = await harness.listWorkspaces(options);
|
||||
const currentValue = typeof harness?.currentWorkspace === 'function'
|
||||
? harness.currentWorkspace()
|
||||
: null;
|
||||
|
|
@ -155,7 +155,7 @@ async function runWorkspaceListCommand(match, harness) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function resolveSessionListWorkspace(selector, harness) {
|
||||
export async function resolveSessionListWorkspace(selector, harness, options = {}) {
|
||||
if (!selector) {
|
||||
if (typeof harness?.currentWorkspace !== 'function') {
|
||||
return { error: t('当前机器人没有可用的工作区。') };
|
||||
|
|
@ -169,7 +169,7 @@ export async function resolveSessionListWorkspace(selector, harness) {
|
|||
if (typeof harness?.listWorkspaces !== 'function') {
|
||||
return { error: t('当前机器人暂不支持按序号选择工作区。') };
|
||||
}
|
||||
const { paths } = await workspacePathSnapshot(harness);
|
||||
const { paths } = await workspacePathSnapshot(harness, options);
|
||||
const position = Number(selector);
|
||||
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
|
||||
return { error: t('工作区序号不存在,请先执行 /workspacelist。') };
|
||||
|
|
|
|||
|
|
@ -151,14 +151,14 @@ test('HarnessClient lists sessions by workspace accounting and forwards request
|
|||
sessionId: 'session-one',
|
||||
blank: false,
|
||||
cwd: '/tmp/target',
|
||||
projections: { values: { title: null } },
|
||||
projections: { asOfSeq: -1, values: { title: null } },
|
||||
},
|
||||
{
|
||||
sessionId: 'session-two',
|
||||
blank: true,
|
||||
origin: 'subagent',
|
||||
cwd: '/tmp/different',
|
||||
projections: { values: { title: 'Second session' } },
|
||||
projections: { asOfSeq: 0, values: { title: 'Second session' } },
|
||||
},
|
||||
{
|
||||
sessionId: 'cwd-only',
|
||||
|
|
@ -180,6 +180,7 @@ test('HarnessClient lists sessions by workspace accounting and forwards request
|
|||
blank: true,
|
||||
origin: 'subagent',
|
||||
summaryAvailable: true,
|
||||
lastSeq: 0,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-missing',
|
||||
|
|
@ -196,6 +197,7 @@ test('HarnessClient lists sessions by workspace accounting and forwards request
|
|||
blank: false,
|
||||
origin: null,
|
||||
summaryAvailable: true,
|
||||
lastSeq: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -93,7 +93,11 @@ test('custom steer card wraps input and submit in a form container', () => {
|
|||
const input = form.elements.find((element) => element.tag === 'input');
|
||||
assert.equal(input?.name, 'steer_text');
|
||||
const submit = form.elements.find((element) => element.tag === 'button');
|
||||
assert.equal(submit?.name, 'steer_submit');
|
||||
assert.equal(submit?.form_action_type, 'submit');
|
||||
assert.equal(submit?.action_type, undefined, 'Card 2.0 must not rely on the legacy action_type field');
|
||||
const controlNames = [form.name, ...form.elements.map((element) => element.name).filter(Boolean)];
|
||||
assert.equal(new Set(controlNames).size, controlNames.length, 'form control names must be card-global unique');
|
||||
assert.deepEqual(submit?.behaviors, [{
|
||||
type: 'callback',
|
||||
value: { action: 'steer', source: 'form' },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,25 @@ class FakeWSClient {
|
|||
this.options.onReady();
|
||||
}
|
||||
|
||||
fail(error = new Error('synthetic WebSocket failure')) {
|
||||
this.state = 'failed';
|
||||
this.options.onError(error);
|
||||
}
|
||||
|
||||
beginReconnecting() {
|
||||
this.state = 'reconnecting';
|
||||
this.options.onReconnecting();
|
||||
}
|
||||
|
||||
becomeReconnected() {
|
||||
this.state = 'connected';
|
||||
this.options.onReconnected();
|
||||
}
|
||||
|
||||
becomeIdle() {
|
||||
this.state = 'idle';
|
||||
}
|
||||
|
||||
getConnectionStatus() {
|
||||
return { state: this.state };
|
||||
}
|
||||
|
|
@ -81,6 +100,16 @@ function fakeLark() {
|
|||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||
let harnessChecks = 0;
|
||||
let harnessSignal;
|
||||
|
|
@ -98,6 +127,7 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
},
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
connectTimeoutMs: 1_234,
|
||||
});
|
||||
|
||||
assert.equal(runtime.status.ready, false);
|
||||
|
|
@ -110,6 +140,7 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
assert.equal(settled, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
||||
assert.equal(FakeWSClient.instances[0].options.agent, wsAgent);
|
||||
assert.equal(FakeWSClient.instances[0].options.handshakeTimeoutMs, 1_234);
|
||||
assert.equal('agent' in FakeClient.instances[0].options, false);
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
const status = await starting;
|
||||
|
|
@ -122,6 +153,19 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
url: 'https://open.feishu.cn/test',
|
||||
})).timeout, 15_000);
|
||||
|
||||
const firstDispatcher = FakeWSClient.instances[0].dispatcher;
|
||||
FakeWSClient.instances[0].beginReconnecting();
|
||||
const reconnecting = await runtime.start();
|
||||
assert.equal(reconnecting.ready, false);
|
||||
assert.equal(reconnecting.feishuLongConnectionState, 'reconnecting');
|
||||
assert.equal(harnessChecks, 1);
|
||||
assert.equal(FakeWSClient.instances.length, 1);
|
||||
assert.equal(FakeClient.instances.length, 1);
|
||||
assert.equal(FakeWSClient.instances[0].dispatcher, firstDispatcher);
|
||||
FakeWSClient.instances[0].becomeReconnected();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'connected');
|
||||
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(FakeClient.sent, [{
|
||||
params: { receive_id_type: 'open_id' },
|
||||
|
|
@ -137,6 +181,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
assert.equal(harnessSignal.aborted, true);
|
||||
|
||||
const stoppedStatus = runtime.status;
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
FakeWSClient.instances[0].fail(new Error('late failure after stop'));
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
FakeWSClient.instances[0].beginReconnecting();
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
FakeWSClient.instances[0].becomeReconnected();
|
||||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
});
|
||||
|
||||
test('FeishuRuntime uses a remembered private target for wildcard-only manual bots', async () => {
|
||||
|
|
@ -173,6 +227,50 @@ test('FeishuRuntime uses a remembered private target for wildcard-only manual bo
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime stop waits for a pending Harness check and prevents startup resurrection', async () => {
|
||||
const harnessReady = deferred();
|
||||
let harnessSignal;
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_delayed_harness',
|
||||
appSecret: 'secret',
|
||||
ownerOpenId: 'ou_owner',
|
||||
harness: {
|
||||
async ensureRunning({ signal }) {
|
||||
harnessSignal = signal;
|
||||
await harnessReady.promise;
|
||||
},
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
});
|
||||
|
||||
const starting = runtime.start();
|
||||
const startRejected = assert.rejects(starting, (error) => error?.name === 'AbortError');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(harnessSignal.aborted, false);
|
||||
|
||||
let stopSettled = false;
|
||||
const stopping = runtime.stop().then((status) => {
|
||||
stopSettled = true;
|
||||
return status;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(harnessSignal.aborted, true);
|
||||
assert.equal(stopSettled, false);
|
||||
assert.equal(FakeClient.instances.length, 0);
|
||||
assert.equal(FakeWSClient.instances.length, 0);
|
||||
|
||||
harnessReady.resolve();
|
||||
await startRejected;
|
||||
const stopped = await stopping;
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
assert.equal(FakeClient.instances.length, 0);
|
||||
assert.equal(FakeWSClient.instances.length, 0);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(runtime.status, stopped);
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
|
|
@ -188,6 +286,17 @@ test('FeishuRuntime fails closed when the initial WebSocket handshake times out'
|
|||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
assert.equal(FakeWSClient.instances[0].options.handshakeTimeoutMs, 10);
|
||||
|
||||
const failedStatus = runtime.status;
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
FakeWSClient.instances[0].fail(new Error('late failure after timeout'));
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
FakeWSClient.instances[0].beginReconnecting();
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
FakeWSClient.instances[0].becomeReconnected();
|
||||
assert.deepEqual(runtime.status, failedStatus);
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when Harness is unavailable', async () => {
|
||||
|
|
@ -226,6 +335,41 @@ async function startRuntimeForProbe(options = {}) {
|
|||
return runtime;
|
||||
}
|
||||
|
||||
test('FeishuRuntime drains failed and idle WS resources before creating a replacement', async () => {
|
||||
const runtime = await startRuntimeForProbe({
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
const firstWsClient = FakeWSClient.instances[0];
|
||||
firstWsClient.fail(new Error('terminal connection failure'));
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||
|
||||
const restartingFromFailure = runtime.start();
|
||||
for (let attempt = 0; attempt < 20 && FakeWSClient.instances.length < 2; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(firstWsClient.state, 'closed');
|
||||
assert.equal(FakeWSClient.instances.length, 2);
|
||||
assert.equal(FakeClient.instances.length, 2);
|
||||
const secondWsClient = FakeWSClient.instances[1];
|
||||
secondWsClient.becomeReady();
|
||||
assert.equal((await restartingFromFailure).ready, true);
|
||||
|
||||
// The SDK snapshot is authoritative even if a transition callback was
|
||||
// missed and Runtime status still says connected.
|
||||
secondWsClient.becomeIdle();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
const restartingFromIdle = runtime.start();
|
||||
for (let attempt = 0; attempt < 20 && FakeWSClient.instances.length < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(secondWsClient.state, 'closed');
|
||||
assert.equal(FakeWSClient.instances.length, 3);
|
||||
assert.equal(FakeClient.instances.length, 3);
|
||||
FakeWSClient.instances[2].becomeReady();
|
||||
assert.equal((await restartingFromIdle).ready, true);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
function probeAction({ messageId = 'message-1', nonce, operatorOpenId = 'ou_owner' } = {}) {
|
||||
return {
|
||||
operator: { open_id: operatorOpenId },
|
||||
|
|
@ -234,6 +378,192 @@ function probeAction({ messageId = 'message-1', nonce, operatorOpenId = 'ou_owne
|
|||
};
|
||||
}
|
||||
|
||||
test('FeishuRuntime dispatcher ACKs immediately while card work is still pending', async () => {
|
||||
const seen = new Set();
|
||||
const runtime = await startRuntimeForProbe({
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
harness: {
|
||||
async ensureRunning() {},
|
||||
async listWorkspaces() { return []; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
sessionFor: () => null,
|
||||
includesArchivedSessions: () => false,
|
||||
},
|
||||
});
|
||||
const handlers = FakeWSClient.instances[0].dispatcher.handlers;
|
||||
|
||||
assert.equal(handlers['im.message.reaction.created_v1']({}), undefined);
|
||||
assert.equal(handlers['im.message.reaction.deleted_v1']({}), undefined);
|
||||
assert.equal(handlers['im.message.receive_v1']({
|
||||
sender: {
|
||||
sender_type: 'user',
|
||||
sender_id: { open_id: 'ou_owner' },
|
||||
},
|
||||
message: {
|
||||
message_id: 'incoming-menu',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '/m' }),
|
||||
},
|
||||
}), undefined);
|
||||
|
||||
for (let attempt = 0; attempt < 20 && FakeClient.sent.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(FakeClient.sent[0]?.data?.msg_type, 'interactive');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let patchCalls = 0;
|
||||
let resolvePatch;
|
||||
const pendingPatch = new Promise((resolve) => { resolvePatch = resolve; });
|
||||
FakeClient.instances[0].im.v1.message.patch = async () => {
|
||||
patchCalls += 1;
|
||||
return pendingPatch;
|
||||
};
|
||||
|
||||
const result = handlers['card.action.trigger']({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'help' } },
|
||||
context: {
|
||||
open_message_id: 'message-1',
|
||||
open_chat_id: 'oc_chat',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, undefined);
|
||||
assert.equal(runtime.status.cardActionsReceived, 1);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(patchCalls, 1);
|
||||
|
||||
resolvePatch({ code: 0, data: { message_id: 'message-1' } });
|
||||
await pendingPatch;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime start waits for an idle-draining stop and preserves the new resources', async () => {
|
||||
const seen = new Set();
|
||||
const runtime = await startRuntimeForProbe({
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
harness: {
|
||||
async ensureRunning() {},
|
||||
async listWorkspaces() { return []; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
sessionFor: () => null,
|
||||
includesArchivedSessions: () => false,
|
||||
},
|
||||
});
|
||||
const firstWsClient = FakeWSClient.instances[0];
|
||||
const handlers = firstWsClient.dispatcher.handlers;
|
||||
handlers['im.message.receive_v1']({
|
||||
sender: {
|
||||
sender_type: 'user',
|
||||
sender_id: { open_id: 'ou_owner' },
|
||||
},
|
||||
message: {
|
||||
message_id: 'restart-menu',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '/m' }),
|
||||
},
|
||||
});
|
||||
for (let attempt = 0; attempt < 20 && FakeClient.sent.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(FakeClient.sent[0]?.data?.msg_type, 'interactive');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const patchEntered = deferred();
|
||||
const patchReleased = deferred();
|
||||
FakeClient.instances[0].im.v1.message.patch = async () => {
|
||||
patchEntered.resolve();
|
||||
return patchReleased.promise;
|
||||
};
|
||||
handlers['card.action.trigger']({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'help' } },
|
||||
context: {
|
||||
open_message_id: 'message-1',
|
||||
open_chat_id: 'oc_chat',
|
||||
},
|
||||
});
|
||||
await patchEntered.promise;
|
||||
|
||||
let stopSettled = false;
|
||||
const stopping = runtime.stop().then((status) => {
|
||||
stopSettled = true;
|
||||
return status;
|
||||
});
|
||||
let restartSettled = false;
|
||||
const restarting = runtime.start().then((status) => {
|
||||
restartSettled = true;
|
||||
return status;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(stopSettled, false);
|
||||
assert.equal(restartSettled, false);
|
||||
assert.equal(FakeWSClient.instances.length, 1);
|
||||
assert.equal(FakeClient.instances.length, 1);
|
||||
assert.equal(firstWsClient.state, 'closed');
|
||||
|
||||
patchReleased.resolve({ code: 0, data: { message_id: 'message-1' } });
|
||||
const stopped = await stopping;
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
for (let attempt = 0; attempt < 20 && FakeWSClient.instances.length < 2; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(FakeWSClient.instances.length, 2);
|
||||
assert.equal(FakeClient.instances.length, 2);
|
||||
|
||||
const secondWsClient = FakeWSClient.instances[1];
|
||||
secondWsClient.becomeReady();
|
||||
const restarted = await restarting;
|
||||
assert.equal(restarted.ready, true);
|
||||
assert.equal(restarted.feishuLongConnectionState, 'connected');
|
||||
assert.equal(secondWsClient.state, 'connected');
|
||||
|
||||
const sentBeforeLateDispatch = FakeClient.sent.length;
|
||||
const cardActionsBeforeLateDispatch = runtime.status.cardActionsReceived;
|
||||
handlers['im.message.receive_v1']({
|
||||
sender: {
|
||||
sender_type: 'user',
|
||||
sender_id: { open_id: 'ou_owner' },
|
||||
},
|
||||
message: {
|
||||
message_id: 'late-old-dispatcher-message',
|
||||
chat_type: 'p2p',
|
||||
chat_id: 'oc_chat',
|
||||
message_type: 'text',
|
||||
content: JSON.stringify({ text: '/m' }),
|
||||
},
|
||||
});
|
||||
handlers['card.action.trigger']({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'help' } },
|
||||
context: {
|
||||
open_message_id: 'message-1',
|
||||
open_chat_id: 'oc_chat',
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(seen.has('late-old-dispatcher-message'), false);
|
||||
assert.equal(runtime.status.cardActionsReceived, cardActionsBeforeLateDispatch);
|
||||
assert.equal(FakeClient.sent.length, sentBeforeLateDispatch);
|
||||
|
||||
assert.deepEqual(await runtime.sendConnectionTest('重启后连接测试'), { sent: true });
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime resolves a card-action probe only for the exact message, nonce and operator', async () => {
|
||||
const runtime = await startRuntimeForProbe();
|
||||
let settled = false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
|
||||
import { LoggerLevel, WSClient } from '@larksuiteoapi/node-sdk';
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const silentLogger = Object.fromEntries(
|
||||
['error', 'warn', 'info', 'debug', 'trace'].map((level) => [level, () => {}]),
|
||||
);
|
||||
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function completeWebSocketHandshake(socket) {
|
||||
let request = '';
|
||||
const handleData = (chunk) => {
|
||||
request += chunk.toString('utf8');
|
||||
if (!request.includes('\r\n\r\n')) {
|
||||
return;
|
||||
}
|
||||
socket.removeListener('data', handleData);
|
||||
const key = request.match(/^Sec-WebSocket-Key:\s*(.+)$/imu)?.[1]?.trim();
|
||||
if (!key) {
|
||||
throw new Error('WebSocket handshake did not contain Sec-WebSocket-Key');
|
||||
}
|
||||
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
|
||||
socket.write([
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
};
|
||||
socket.on('data', handleData);
|
||||
}
|
||||
|
||||
async function runScenario(mode) {
|
||||
let acceptedConnections = 0;
|
||||
const sockets = new Set();
|
||||
const server = createServer((socket) => {
|
||||
acceptedConnections += 1;
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
socket.on('error', () => {});
|
||||
if (mode === 'connected-repeat') {
|
||||
completeWebSocketHandshake(socket);
|
||||
}
|
||||
else {
|
||||
// Consume the HTTP upgrade bytes without responding. This keeps the
|
||||
// WebSocket handshake blackholed while allowing FIN/RST to surface as a
|
||||
// server-side close when the client aborts the pending socket.
|
||||
socket.resume();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const { port } = server.address();
|
||||
let endpointPulls = 0;
|
||||
let callbackCount = 0;
|
||||
const client = new WSClient({
|
||||
appId: 'cli_0123456789abcdef',
|
||||
appSecret: 'test-secret',
|
||||
autoReconnect: !['failed-restart', 'timeout'].includes(mode),
|
||||
handshakeTimeoutMs: ['connected-repeat', 'duplicate-start', 'restart'].includes(mode)
|
||||
? 500
|
||||
: 50,
|
||||
httpInstance: {
|
||||
async request() {
|
||||
endpointPulls += 1;
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
URL: `ws://127.0.0.1:${port}/blackhole`,
|
||||
ClientConfig: {
|
||||
PingInterval: 120,
|
||||
ReconnectCount: 5,
|
||||
ReconnectInterval: 0.01,
|
||||
ReconnectNonce: 0,
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
};
|
||||
},
|
||||
},
|
||||
logger: silentLogger,
|
||||
loggerLevel: LoggerLevel.error,
|
||||
onError: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
onReady: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
onReconnected: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
onReconnecting: () => {
|
||||
callbackCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
if (mode === 'duplicate-start') {
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
}
|
||||
await waitFor(() => acceptedConnections >= 1, 'first WebSocket handshake');
|
||||
|
||||
if (mode === 'duplicate-start') {
|
||||
await delay(50);
|
||||
const stateBeforeClose = client.getConnectionStatus().state;
|
||||
const activeSocketsBeforeClose = sockets.size;
|
||||
if (stateBeforeClose !== 'connecting' || endpointPulls !== 1 ||
|
||||
acceptedConnections !== 1 || activeSocketsBeforeClose !== 1 || callbackCount !== 0) {
|
||||
throw new Error(JSON.stringify({
|
||||
stateBeforeClose,
|
||||
endpointPulls,
|
||||
acceptedConnections,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
}));
|
||||
}
|
||||
|
||||
client.close({ force: true });
|
||||
await waitFor(() => sockets.size === 0, 'duplicate-start socket shutdown');
|
||||
await delay(100);
|
||||
const activeSocketsAfterClose = sockets.size;
|
||||
if (endpointPulls !== 1 || acceptedConnections !== 1 || callbackCount !== 0) {
|
||||
throw new Error('duplicate start produced a callback or reconnect after close');
|
||||
}
|
||||
return {
|
||||
acceptedConnections,
|
||||
activeSocketsAfterClose,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
endpointPulls,
|
||||
stateBeforeClose,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'connected-repeat') {
|
||||
await waitFor(
|
||||
() => client.getConnectionStatus().state === 'connected',
|
||||
'connected WebSocket state',
|
||||
);
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
await delay(100);
|
||||
|
||||
const stateBeforeClose = client.getConnectionStatus().state;
|
||||
const activeSocketsBeforeClose = sockets.size;
|
||||
if (stateBeforeClose !== 'connected' || endpointPulls !== 1 ||
|
||||
acceptedConnections !== 1 || activeSocketsBeforeClose !== 1 || callbackCount !== 1) {
|
||||
throw new Error(JSON.stringify({
|
||||
stateBeforeClose,
|
||||
endpointPulls,
|
||||
acceptedConnections,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
}));
|
||||
}
|
||||
|
||||
client.close({ force: true });
|
||||
await waitFor(() => sockets.size === 0, 'connected socket shutdown');
|
||||
await delay(100);
|
||||
const activeSocketsAfterClose = sockets.size;
|
||||
if (endpointPulls !== 1 || acceptedConnections !== 1 || callbackCount !== 1) {
|
||||
throw new Error('connected repeat start produced a duplicate callback or reconnect');
|
||||
}
|
||||
return {
|
||||
acceptedConnections,
|
||||
activeSocketsAfterClose,
|
||||
activeSocketsBeforeClose,
|
||||
callbackCount,
|
||||
endpointPulls,
|
||||
stateBeforeClose,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'failed-restart') {
|
||||
await waitFor(() => client.getConnectionStatus().state === 'failed', 'terminal failure state');
|
||||
const callbackCountBeforeRestart = callbackCount;
|
||||
if (endpointPulls !== 1 || callbackCountBeforeRestart !== 1) {
|
||||
throw new Error('initial terminal failure did not settle exactly once');
|
||||
}
|
||||
await waitFor(() => sockets.size === 0, 'failed socket shutdown');
|
||||
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
await waitFor(() => acceptedConnections >= 2, 'post-failure WebSocket handshake');
|
||||
const stateAfterRestart = client.getConnectionStatus().state;
|
||||
if (endpointPulls !== 2 || stateAfterRestart !== 'connecting') {
|
||||
throw new Error(`failed client did not restart: pulls=${endpointPulls}, state=${stateAfterRestart}`);
|
||||
}
|
||||
client.close({ force: true });
|
||||
await waitFor(() => sockets.size === 0, 'post-failure restarted socket shutdown');
|
||||
await delay(100);
|
||||
if (endpointPulls !== 2 || callbackCount !== callbackCountBeforeRestart) {
|
||||
throw new Error('post-failure restart produced a stale callback or reconnect');
|
||||
}
|
||||
return { callbackCountBeforeRestart, endpointPulls, stateAfterRestart };
|
||||
}
|
||||
|
||||
if (mode === 'restart') {
|
||||
client.close({ force: true });
|
||||
client.start({ eventDispatcher: { invoke: async () => undefined } });
|
||||
await waitFor(() => acceptedConnections >= 2, 'restarted WebSocket handshake');
|
||||
await delay(50);
|
||||
|
||||
const stateBeforeFinalClose = client.getConnectionStatus().state;
|
||||
if (stateBeforeFinalClose !== 'connecting') {
|
||||
throw new Error(
|
||||
`stale initial continuation replaced restarted state with ${stateBeforeFinalClose}`,
|
||||
);
|
||||
}
|
||||
if (endpointPulls !== 2) {
|
||||
throw new Error(`expected two endpoint pulls after immediate restart, received ${endpointPulls}`);
|
||||
}
|
||||
if (callbackCount !== 0) {
|
||||
throw new Error(`stale connection invoked ${callbackCount} lifecycle callbacks`);
|
||||
}
|
||||
|
||||
client.close({ force: true });
|
||||
await delay(100);
|
||||
if (endpointPulls !== 2) {
|
||||
throw new Error(`old continuation triggered a third endpoint pull (${endpointPulls})`);
|
||||
}
|
||||
return { callbackCount, endpointPulls, stateBeforeFinalClose };
|
||||
}
|
||||
|
||||
if (mode === 'close') {
|
||||
// close() happens while ws is still CONNECTING. The patched SDK must
|
||||
// abort that pending socket and cancel the old reConnect(true)
|
||||
// continuation before it can pull another endpoint.
|
||||
client.close({ force: true });
|
||||
await delay(150);
|
||||
}
|
||||
else {
|
||||
// Let the handshake watchdog terminate the blackholed CONNECTING socket.
|
||||
// The retained `error` listener must prevent an uncaught EventEmitter
|
||||
// error. autoReconnect=false keeps this scenario to one endpoint pull.
|
||||
await delay(150);
|
||||
client.close({ force: true });
|
||||
}
|
||||
|
||||
if (endpointPulls !== 1) {
|
||||
throw new Error(`expected one endpoint pull after close, received ${endpointPulls}`);
|
||||
}
|
||||
return endpointPulls;
|
||||
}
|
||||
finally {
|
||||
client.close({ force: true });
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate, label) {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const handshakeTimeoutPulls = await runScenario('timeout');
|
||||
const closeDuringHandshakePulls = await runScenario('close');
|
||||
const immediateRestart = await runScenario('restart');
|
||||
const duplicateStart = await runScenario('duplicate-start');
|
||||
const connectedRepeatStart = await runScenario('connected-repeat');
|
||||
const failedRestart = await runScenario('failed-restart');
|
||||
process.stdout.write(JSON.stringify({
|
||||
handshakeTimeoutPulls,
|
||||
closeDuringHandshakePulls,
|
||||
immediateRestart,
|
||||
duplicateStart,
|
||||
connectedRepeatStart,
|
||||
failedRestart,
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -321,14 +321,14 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
sessionId: 'session-one',
|
||||
blank: false,
|
||||
cwd: '/tmp/target',
|
||||
projections: { values: { title: null } },
|
||||
projections: { asOfSeq: -1, values: { title: null } },
|
||||
},
|
||||
{
|
||||
sessionId: 'session-two',
|
||||
blank: true,
|
||||
origin: 'subagent',
|
||||
cwd: '/tmp/different',
|
||||
projections: { values: { title: 'Second session' } },
|
||||
projections: { asOfSeq: 0, values: { title: 'Second session' } },
|
||||
},
|
||||
{
|
||||
sessionId: 'cwd-only',
|
||||
|
|
@ -350,6 +350,7 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
blank: true,
|
||||
origin: 'subagent',
|
||||
summaryAvailable: true,
|
||||
lastSeq: 0,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-missing',
|
||||
|
|
@ -366,6 +367,7 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
blank: false,
|
||||
origin: null,
|
||||
summaryAvailable: true,
|
||||
lastSeq: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
125
test/channels/feishu/lark-sdk-handshake-patch.test.mjs
Normal file
125
test/channels/feishu/lark-sdk-handshake-patch.test.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
import {
|
||||
larkSdkHandshakePatch,
|
||||
patchLarkSdkHandshakeSource,
|
||||
} from '../../../plugin-src/host/lark-sdk-handshake-patch.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const testDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test('Lark SDK patch applies every reviewed lifecycle fix to both vendor builds', async () => {
|
||||
const sdkRoot = dirname(require.resolve('@larksuiteoapi/node-sdk/package.json'));
|
||||
|
||||
for (const flavor of ['es', 'lib']) {
|
||||
const sdkPath = resolve(sdkRoot, flavor, 'index.js');
|
||||
const source = await readFile(sdkPath, 'utf8');
|
||||
const patched = patchLarkSdkHandshakeSource(source, sdkPath);
|
||||
|
||||
assert.match(patched, /this\.pendingWsInstance = null;/u);
|
||||
assert.match(patched, /this\.pendingWsInstance = wsInstance;/u);
|
||||
assert.match(patched, /this\.pendingWsInstance === wsInstance/u);
|
||||
assert.match(patched, /wsInstance\.removeAllListeners\(['"]open['"]\);/u);
|
||||
assert.match(patched, /pendingWsInstance\.terminate\(\);/u);
|
||||
assert.match(
|
||||
patched,
|
||||
/finally \{\s+if \(currentGeneration === this\.reconnectGeneration\) \{\s+this\.isConnecting = false;/u,
|
||||
);
|
||||
assert.match(
|
||||
patched,
|
||||
/if \(this\.terminalError\) \{\s+this\.isConnecting = false;[^]*?if \(this\.isConnecting \|\|[^]*?liveWsInstance\.readyState !== WebSocket\.CLOSED/u,
|
||||
);
|
||||
assert.match(patched, /currentGeneration !== this\.reconnectGeneration \|\| result\.cancelled/u);
|
||||
assert.match(patched, /wsInstance\.on\(['"]error['"]/u);
|
||||
assert.doesNotMatch(
|
||||
patched,
|
||||
/handshake timeout[^]*?wsInstance\.removeAllListeners\(\);[^]*?settleOnce\(false\);/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Lark SDK handshake patch fails closed when the reviewed vendor shape changes', () => {
|
||||
assert.throws(
|
||||
() => patchLarkSdkHandshakeSource('wsInstance.terminate();', 'changed-sdk.js'),
|
||||
/expected exactly one reviewed WSClient pending-socket state marker, found 0/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('patched real SDK survives handshake timeout and close without uncaught or zombie reconnects', async () => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'dsh-lark-sdk-test-'));
|
||||
const outputPath = join(temporaryDirectory, 'scenario.mjs');
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [resolve(testDirectory, 'fixtures/lark-sdk-close-during-handshake.mjs')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node22'],
|
||||
mainFields: ['module', 'main'],
|
||||
plugins: [larkSdkHandshakePatch],
|
||||
outfile: outputPath,
|
||||
logLevel: 'silent',
|
||||
banner: {
|
||||
js: [
|
||||
"import { createRequire as __dshCreateRequire } from 'node:module';",
|
||||
"import { dirname as __dshDirname } from 'node:path';",
|
||||
"import { fileURLToPath as __dshFileURLToPath } from 'node:url';",
|
||||
'const require = __dshCreateRequire(import.meta.url);',
|
||||
'const __filename = __dshFileURLToPath(import.meta.url);',
|
||||
'const __dirname = __dshDirname(__filename);',
|
||||
].join('\n'),
|
||||
},
|
||||
});
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [outputPath], {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(JSON.parse(stdout), {
|
||||
handshakeTimeoutPulls: 1,
|
||||
closeDuringHandshakePulls: 1,
|
||||
immediateRestart: {
|
||||
callbackCount: 0,
|
||||
endpointPulls: 2,
|
||||
stateBeforeFinalClose: 'connecting',
|
||||
},
|
||||
duplicateStart: {
|
||||
acceptedConnections: 1,
|
||||
activeSocketsAfterClose: 0,
|
||||
activeSocketsBeforeClose: 1,
|
||||
callbackCount: 0,
|
||||
endpointPulls: 1,
|
||||
stateBeforeClose: 'connecting',
|
||||
},
|
||||
connectedRepeatStart: {
|
||||
acceptedConnections: 1,
|
||||
activeSocketsAfterClose: 0,
|
||||
activeSocketsBeforeClose: 1,
|
||||
callbackCount: 1,
|
||||
endpointPulls: 1,
|
||||
stateBeforeClose: 'connected',
|
||||
},
|
||||
failedRestart: {
|
||||
callbackCountBeforeRestart: 1,
|
||||
endpointPulls: 2,
|
||||
stateAfterRestart: 'connecting',
|
||||
},
|
||||
});
|
||||
assert.equal(stderr, '');
|
||||
}
|
||||
finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -265,14 +265,14 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
sessionId: 'session-one',
|
||||
blank: false,
|
||||
cwd: '/tmp/target',
|
||||
projections: { values: { title: null } },
|
||||
projections: { asOfSeq: -1, values: { title: null } },
|
||||
},
|
||||
{
|
||||
sessionId: 'session-two',
|
||||
blank: true,
|
||||
origin: 'subagent',
|
||||
cwd: '/tmp/different',
|
||||
projections: { values: { title: 'Second session' } },
|
||||
projections: { asOfSeq: 0, values: { title: 'Second session' } },
|
||||
},
|
||||
{
|
||||
sessionId: 'cwd-only',
|
||||
|
|
@ -294,6 +294,7 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
blank: true,
|
||||
origin: 'subagent',
|
||||
summaryAvailable: true,
|
||||
lastSeq: 0,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-missing',
|
||||
|
|
@ -310,6 +311,7 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
|
|||
blank: false,
|
||||
origin: null,
|
||||
summaryAvailable: true,
|
||||
lastSeq: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,11 +43,14 @@ function fixture({
|
|||
running = false,
|
||||
activeTurn = false,
|
||||
selectionError = null,
|
||||
selectionResult,
|
||||
selectionReadback,
|
||||
selectModelHook,
|
||||
setSessionResult,
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
let boundId = initialSessionId;
|
||||
const selectedModels = new Map();
|
||||
const session = (sessionId) => ({
|
||||
async sessionExists(options) {
|
||||
calls.push(['sessionExists', sessionId, options]);
|
||||
|
|
@ -56,7 +59,8 @@ function fixture({
|
|||
async models(options) {
|
||||
calls.push(['models', sessionId, options]);
|
||||
if (sessionCatalog instanceof Error) throw sessionCatalog;
|
||||
return sessionCatalog;
|
||||
const current = selectedModels.get(sessionId) ?? sessionCatalog.current;
|
||||
return { ...sessionCatalog, current };
|
||||
},
|
||||
async isRunning(options) {
|
||||
calls.push(['isRunning', sessionId, options]);
|
||||
|
|
@ -70,7 +74,9 @@ function fixture({
|
|||
calls.push(['selectModel', sessionId, selection, options]);
|
||||
if (selectModelHook) await selectModelHook({ sessionId, selection, options });
|
||||
if (selectionError) throw selectionError;
|
||||
return { selected: selection };
|
||||
const selected = selectionResult ?? selection;
|
||||
selectedModels.set(sessionId, selectionReadback ?? selection);
|
||||
return { selected };
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
|
|
@ -282,6 +288,8 @@ test('/model creates and selects a blank Session before exposing its binding', a
|
|||
const operations = calls.map(([name]) => name);
|
||||
assert.ok(operations.indexOf('listModels') < operations.indexOf('createSession'));
|
||||
assert.ok(operations.indexOf('createSession') < operations.indexOf('selectModel'));
|
||||
assert.ok(operations.lastIndexOf('models') > operations.indexOf('selectModel'));
|
||||
assert.ok(operations.lastIndexOf('models') < operations.indexOf('setSession'));
|
||||
assert.ok(operations.indexOf('selectModel') < operations.indexOf('setSession'));
|
||||
assert.deepEqual(calls.find(([name]) => name === 'selectModel'), [
|
||||
'selectModel',
|
||||
|
|
@ -291,6 +299,45 @@ test('/model creates and selects a blank Session before exposing its binding', a
|
|||
]);
|
||||
});
|
||||
|
||||
test('a mismatched selectModel result is reported as a failed switch', async () => {
|
||||
const { calls, harness, state } = fixture({
|
||||
initialSessionId: 'session-one',
|
||||
selectionResult: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
});
|
||||
|
||||
const result = await runModelCommand(
|
||||
'/model deepseek-official/deepseek-v4-pro',
|
||||
harness,
|
||||
state,
|
||||
'direct:one',
|
||||
);
|
||||
|
||||
assert.match(result.message, /模型切换失败/);
|
||||
assert.match(result.message, /requested: deepseek-official\/deepseek-v4-pro/);
|
||||
assert.match(result.message, /selectModel\.selected: deepseek-official\/deepseek-v4-flash/);
|
||||
assert.equal(calls.filter(([name]) => name === 'models').length, 1);
|
||||
});
|
||||
|
||||
test('an authoritative current-model mismatch is reported and leaves a new Session unbound', async () => {
|
||||
const { calls, harness, state, boundId } = fixture({
|
||||
selectionReadback: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
});
|
||||
|
||||
const result = await runModelCommand(
|
||||
'/model deepseek-official/deepseek-v4-pro',
|
||||
harness,
|
||||
state,
|
||||
'direct:one',
|
||||
);
|
||||
|
||||
assert.match(result.message, /模型切换失败/);
|
||||
assert.match(result.message, /requested: deepseek-official\/deepseek-v4-pro/);
|
||||
assert.match(result.message, /当前模型: deepseek-official\/deepseek-v4-flash/);
|
||||
assert.equal(boundId(), null);
|
||||
assert.equal(calls.filter(([name]) => name === 'models').length, 1);
|
||||
assert.equal(calls.some(([name]) => name === 'setSession'), false);
|
||||
});
|
||||
|
||||
test('a failed first model selection leaves the conversation unbound', async () => {
|
||||
const selectionError = new Error('provider unavailable');
|
||||
selectionError.code = 'model-unavailable';
|
||||
|
|
@ -367,6 +414,34 @@ test('a concurrent external binding is preserved after selecting an unbound Sess
|
|||
)), false);
|
||||
});
|
||||
|
||||
test('a concurrent external rebind is preserved after selecting a bound Session', async () => {
|
||||
const selectionStarted = deferred();
|
||||
const releaseSelection = deferred();
|
||||
const fixtureValue = fixture({
|
||||
initialSessionId: 'session-one',
|
||||
selectModelHook: async ({ sessionId }) => {
|
||||
if (sessionId !== 'session-one') return;
|
||||
selectionStarted.resolve();
|
||||
await releaseSelection.promise;
|
||||
},
|
||||
});
|
||||
|
||||
const switching = runModelCommand(
|
||||
'/model deepseek-official/deepseek-v4-pro',
|
||||
fixtureValue.harness,
|
||||
fixtureValue.state,
|
||||
'direct:one',
|
||||
);
|
||||
await selectionStarted.promise;
|
||||
await fixtureValue.state.setSession('direct:one', 'session-two');
|
||||
releaseSelection.resolve();
|
||||
|
||||
const result = await switching;
|
||||
assert.match(result.message, /会话已发生变化.*重试/);
|
||||
assert.equal(fixtureValue.boundId(), 'session-two');
|
||||
assert.doesNotMatch(result.message, /模型已切换为/);
|
||||
});
|
||||
|
||||
test('/model refuses pending interactions and active or running Sessions', async () => {
|
||||
const pending = fixture({ initialSessionId: 'session-one' });
|
||||
const pendingResult = await runModelCommand(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue