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

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

View file

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

View file

@ -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'));

View file

@ -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。') };