fix(feishu): conform card form submission and model select to upstream

- read form data from action.form_value (Card 2.0) instead of formValue

- wrap custom steer input and submit in a form container with submit button

- reuse runModelCommand for dropdown model picks so IDs with multiple / keep working

- pass key when sending the help card so back-to-menu stays valid

- compute initial_index from {value} option arrays

- add in-flight tests for menu stop, steer dropdown, and steer form
This commit is contained in:
C3H3-AI 2026-08-23 07:11:17 +08:00
parent 7d2c93da86
commit 0241a32ec5
4 changed files with 297 additions and 59 deletions

View file

@ -2868,3 +2868,177 @@ test('archived sessions are hidden by default; /archived on reveals them', async
const useOn = useActionsFromCard(cards.at(-1));
assert.deepEqual(useOn, ['live-session', 'old-session'], '/archived on restores archived sessions');
});
// ── stop / steer while a task is running (menu card interactions) ──────────
function activeTurnHarness({ stopped = true, steered = true } = {}) {
const calls = { stop: [], steer: [], sessions: [] };
return {
calls,
harness: {
ensureRunning: async () => true,
workspaceSession: (id) => {
calls.sessions.push(id);
return {
async stopActiveTurn(control, options) {
calls.stop.push({ control, options });
return stopped;
},
async steerActiveTurn(text, control, options) {
calls.steer.push({ text, control, options });
return steered;
},
};
},
},
};
}
function steerDropdownEvent(messageId, option, operatorOpenId) {
return {
operator: { open_id: operatorOpenId },
action: { value: { action: 'steer_pick' }, option },
context: { open_message_id: messageId },
};
}
test('menu stop button stops the bound active turn without touching the model', async () => {
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
const sent = [];
const { calls, harness } = activeTurnHarness();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
harness,
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('menu-stop-open', '/m', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
assert.equal(sent.length, 1);
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(calls.sessions.some((id) => id === 'session-active'), true);
assert.equal(calls.stop.length, 1);
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
assert.match(JSON.parse(sent.at(-1).content).text, /已请求停止当前任务/);
});
test('menu steer dropdown sends a quick instruction to the active turn', async () => {
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
const sent = [];
const { calls, harness } = activeTurnHarness();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
harness,
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('menu-steer-open', '/m', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
assert.equal(sent.length, 1);
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(calls.steer.length, 1);
assert.equal(calls.steer[0].text, '继续');
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
});
test('menu steer custom option opens the form card without steering', async () => {
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
const sent = [];
const { calls, harness } = activeTurnHarness();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
harness,
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('menu-steer-custom-open', '/m', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(calls.steer.length, 0, 'custom option must not steer yet');
const customCard = sent.at(-1);
assert.equal(customCard.msgType, 'interactive');
const form = customCard.content.body.elements.find((el) => el.tag === 'form');
assert.ok(form, 'custom steer card must contain a form');
const submit = form.elements.find((el) => el.tag === 'button');
assert.equal(submit?.form_action_type, 'submit');
});
test('custom steer form submission reads form_value and steers the active turn', async () => {
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
const sent = [];
const { calls, harness } = activeTurnHarness();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
harness,
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('steer-form-open', '/m', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
// 主菜单 steer 下拉选「更多/自定义」→ 发送 custom 表单卡(第二次 create → om_card_2)
await bridge.onCardAction(steerDropdownEvent('om_card_1', 'custom', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(sent.at(-1).msgType, 'interactive');
await bridge.onCardAction({
operator: { open_id: 'ou_owner' },
action: {
value: { action: 'steer', source: 'form' },
form_value: { steer_text: '更简洁些' },
},
context: { open_message_id: 'om_card_2' },
});
await bridge.waitForIdle();
assert.equal(calls.steer.length, 1);
assert.equal(calls.steer[0].text, '更简洁些');
assert.equal(calls.steer[0].control.key, 'p2p:ou_owner');
assert.match(JSON.parse(sent.at(-1).content).text, /已提交补充指令/);
});
test('menu stop and steer reply friendly when no session is bound', async () => {
const fixture = stateFixture();
const sent = [];
const { calls, harness } = activeTurnHarness();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
harness,
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('menu-nosession-open', '/m', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(calls.stop.length, 0);
assert.match(JSON.parse(sent.at(-1).content).text, /没有正在运行/);
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(calls.steer.length, 0);
assert.match(JSON.parse(sent.at(-1).content).text, /普通消息/);
});

View file

@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
cardActionProbeCard,
customSteerCard,
menuCard,
menuHelpText,
modelCard,
} from '../../../src/channels/feishu/feishu-cards.mjs';
function buttons(value, result = []) {
@ -17,6 +19,28 @@ function buttons(value, result = []) {
return result;
}
function selects(value, result = []) {
if (Array.isArray(value)) {
for (const item of value) selects(item, result);
return result;
}
if (!value || typeof value !== 'object') return result;
if (value.tag === 'select_static') result.push(value);
for (const child of Object.values(value)) selects(child, result);
return result;
}
function forms(value, result = []) {
if (Array.isArray(value)) {
for (const item of value) forms(item, result);
return result;
}
if (!value || typeof value !== 'object') return result;
if (value.tag === 'form') result.push(value);
for (const child of Object.values(value)) forms(child, result);
return result;
}
test('menu exposes the increased command set and keeps repair number-only', () => {
const card = JSON.parse(menuCard());
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
@ -49,3 +73,57 @@ test('card-action probe carries only its action and opaque nonce', () => {
}]);
assert.throws(() => cardActionProbeCard('{{client_id}}'), /safe card-action probe nonce/);
});
test('custom steer card wraps input and submit in a form container', () => {
const card = JSON.parse(customSteerCard());
const form = forms(card)[0];
assert.ok(form, 'custom steer card must contain a form container');
assert.equal(form.name, 'steer_form');
const inputs = buttons(form).filter((element) => element.tag === 'input');
// inputs are not buttons; scan form.elements directly
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?.form_action_type, 'submit');
assert.deepEqual(submit?.behaviors, [{
type: 'callback',
value: { action: 'steer', source: 'form' },
}]);
// 表单外的返回菜单按钮仍保留
const back = buttons(card).find((b) => b.behaviors?.[0]?.value?.action === 'back_to_menu');
assert.ok(back);
});
test('menu session dropdown highlights the currently bound session via initial_index', () => {
const sessions = [
{ id: 'session-1', title: 'First' },
{ id: 'session-2', title: 'Second' },
{ id: 'session-3', title: 'Third' },
];
const card = JSON.parse(menuCard({
currentSession: { id: 'session-2', title: 'Second' },
sessions,
}));
const pick = selects(card).find((s) => s.name === 'session_pick');
assert.ok(pick, 'menu must render a session dropdown');
// initial_index is 1-based; the currently bound session sits at index 2.
assert.equal(pick.initial_index, 2);
});
test('model card dropdown highlights the current model via initial_index', () => {
const catalog = {
groups: [
{ id: 'openrouter', name: 'OpenRouter', models: [
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4' },
{ id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat' },
] },
],
current: { provider: 'openrouter', model: 'deepseek/deepseek-chat' },
};
const card = JSON.parse(modelCard(catalog));
const pick = selects(card).find((s) => s.name === 'model_pick');
assert.ok(pick, 'model card must render a dropdown');
// deepseek/deepseek-chat is the second option (1-based index 2), even
// though the id itself contains a `/`.
assert.equal(pick.initial_index, 2);
});