fix(feishu): stabilize post-merge card workflows

This commit is contained in:
xmanrui 2026-08-24 17:56:06 +08:00
parent 9d1dc71794
commit bd9c58cf9e
19 changed files with 3372 additions and 456 deletions

View file

@ -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(