feat: add model and turn control commands

This commit is contained in:
xmanrui 2026-08-20 01:28:51 +08:00
parent fc8f120e13
commit 99ce38e9a4
38 changed files with 3451 additions and 169 deletions

View file

@ -321,6 +321,43 @@ test('DingTalk executes /compact for the bound Session without prompting the mod
assert.equal(sent.at(-1).text, '暂无可压缩的历史记录。');
});
test('DingTalk lists models without prompting and help advertises all four commands', async () => {
const fixture = stateFixture();
const sent = [];
let asks = 0;
let creates = 0;
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
listModels: async () => ({
groups: [{
id: 'dingtalk-provider',
name: 'DingTalk Provider',
models: [{ id: 'model-one', name: 'Model One' }],
}],
failures: [],
}),
createSession: async () => { creates += 1; return 'dingtalk-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
state: fixture.state,
});
await bridge.accept(message('models-dingtalk', '/models'));
assert.match(sent.at(-1).text, /dingtalk-provider\/model-one/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('help-models-dingtalk', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
});
test('bridge maps a DingTalk direct conversation to one persistent Harness session', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -1047,7 +1047,7 @@ test('reconnect history restores a Code Mode sub-call before replaying its appro
await Promise.allSettled([asking]);
});
test('interaction callbacks preserve frame order and watcher shutdown drains them', async () => {
test('interaction callbacks preserve frame order but watcher abort does not await a hanging callback', async () => {
const opened = deferred();
const releaseRequested = deferred();
const order = [];
@ -1102,10 +1102,10 @@ test('interaction callbacks preserve frame order and watcher shutdown drains the
let settled = false;
void watching.finally(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(settled, false);
assert.equal(settled, true);
releaseRequested.resolve();
await watching;
await eventually(() => order.length === 3);
assert.deepEqual(order, ['requested:start', 'requested:end', 'resolved']);
});

View file

@ -161,6 +161,46 @@ test('Feishu executes /compact for the bound Session without prompting the model
assert.deepEqual(sent, ['暂无可压缩的历史记录。']);
});
test('Feishu lists models without prompting and help advertises all four commands', async () => {
const fixture = stateFixture();
const sent = [];
let asks = 0;
let creates = 0;
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => sent.push(text)),
channel: {},
harness: {
listModels: async () => ({
groups: [{
id: 'feishu-provider',
name: 'Feishu Provider',
models: [{ id: 'model-one', name: 'Model One' }],
}],
failures: [],
}),
createSession: async () => { creates += 1; return 'feishu-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
await bridge.accept(event('models-feishu', '/models'));
await bridge.waitForIdle();
assert.match(sent.at(-1), /feishu-provider\/model-one/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(event('help-feishu', '/help'));
await bridge.waitForIdle();
const help = sent.at(-1);
for (const command of ['/models', '/model', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
});
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
const sent = [];
const reactions = [];

View file

@ -259,6 +259,42 @@ test('QQ executes /compact for the bound Session without prompting the model', a
assert.equal(fixture.seen.has('compact-qq'), true);
});
test('QQ lists models without prompting and help advertises all four commands', async () => {
const fixture = stateFixture();
const sent = [];
let asks = 0;
let creates = 0;
const bridge = new QqHarnessBridge({
bot: { sendText: async (_target, text) => sent.push(text) },
ownerUserOpenid: 'owner-openid',
harness: {
listModels: async () => ({
groups: [{
id: 'qq-provider',
name: 'QQ Provider',
models: [{ id: 'model-one', name: 'Model One' }],
}],
failures: [],
}),
createSession: async () => { creates += 1; return 'qq-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
state: fixture.state,
});
await bridge.accept(message({ messageId: 'models-qq', content: '/models' }));
assert.match(sent.at(-1), /qq-provider\/model-one/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message({ messageId: 'help-models-qq', content: '/help' }));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
});
test('QQ remembers any authorized private inbound as a connection-test target', async () => {
const fixture = stateFixture();
const sent = [];
@ -337,6 +373,80 @@ test('QQ private messages stream Harness snapshots and finalize once', async ()
assert.equal(bridge.status.messagesReplied, 1);
});
test('QQ closes an opened progress stream and announces when the Harness turn is stopped', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]);
const frames = [];
const sent = [];
let cancellations = 0;
let loggedErrors = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: () => ({
update: async (text) => frames.push(text),
complete: async () => frames.push('DONE'),
cancel: () => { cancellations += 1; },
}),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, { onUpdate }) => {
await onUpdate({ type: 'tool', name: 'bash' });
const error = new Error('turn stopped');
error.code = 'turn-stopped';
throw error;
},
},
state: fixture.state,
logger: { warn() {}, error() { loggedErrors += 1; } },
});
await bridge.accept(message({ messageId: 'qq-stopped-stream' }));
assert.deepEqual(frames, ['正在使用bash…']);
assert.equal(cancellations, 1);
assert.deepEqual(sent, ['已停止。']);
assert.equal(loggedErrors, 0);
assert.equal(fixture.seen.has('qq-stopped-stream'), true);
});
test('QQ keeps a stopped turn terminal when stream cleanup and its notice both fail', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped-fallback']]);
let warnings = 0;
let loggedErrors = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async () => { throw new Error('send unavailable'); },
openStream: () => ({
update: async () => {},
complete: async () => {},
cancel: () => { throw new Error('cancel unavailable'); },
}),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('turn stopped');
error.code = 'turn-stopped';
throw error;
},
},
state: fixture.state,
logger: {
warn() { warnings += 1; },
error() { loggedErrors += 1; },
},
});
await bridge.accept(message({ messageId: 'qq-stopped-stream-fallback' }));
assert.equal(warnings, 2);
assert.equal(loggedErrors, 0);
assert.equal(fixture.seen.has('qq-stopped-stream-fallback'), true);
});
test('QQ bridge accepts only the scanner and requires an at-message event in groups', async () => {
let asks = 0;
const state = {

View file

@ -0,0 +1,483 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
import { HarnessClient } from '../../../src/channels/shared/harness-client.mjs';
import { WORKSPACE_SESSION_STALE } from '../../../src/channels/shared/workspace-session.mjs';
const CATALOG = {
groups: [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4', name: 'DeepSeek V4' }],
}],
failures: [],
};
function deferred() {
let resolve;
const promise = new Promise((promiseResolve) => { resolve = promiseResolve; });
return { promise, resolve };
}
function modelClient(options = {}) {
const calls = [];
const responses = new Map([
['llm.models', CATALOG],
['session.models', {
...CATALOG,
current: { provider: 'deepseek-official', model: 'deepseek-v4' },
routable: true,
}],
['session.selectModel', {
selected: { provider: 'deepseek-official', model: 'deepseek-v4' },
}],
['session.list', {
items: [
{ sessionId: 'session-one', running: true },
{ sessionId: 'session-two', running: false },
],
}],
]);
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3981',
workspace: '/tmp/workspace',
...options,
});
client.ensureRunning = async (options) => calls.push(['ensureRunning', options]);
client.rpc = async (method, payload, timeoutMs, options) => {
calls.push(['rpc', method, payload, timeoutMs, options]);
return responses.get(method);
};
return { calls, client, responses };
}
test('HarnessClient exposes and validates model and run-state RPCs', async () => {
const { calls, client } = modelClient();
const signal = new AbortController().signal;
const options = { signal };
assert.equal(await client.listModels(options), CATALOG);
assert.deepEqual((await client.getSessionModels('session-one', options)).current, {
provider: 'deepseek-official', model: 'deepseek-v4',
});
assert.deepEqual(await client.selectSessionModel('session-one', {
provider: 'deepseek-official', model: 'deepseek-v4',
}, options), {
selected: { provider: 'deepseek-official', model: 'deepseek-v4' },
});
assert.equal(await client.isSessionRunning('session-one', options), true);
assert.equal(await client.isSessionRunning('missing', options), false);
assert.deepEqual(calls.filter(([type]) => type === 'rpc').map(([, method, payload]) => (
[method, payload]
)), [
['llm.models', {}],
['session.models', { sessionId: 'session-one' }],
['session.selectModel', {
sessionId: 'session-one',
provider: 'deepseek-official',
model: 'deepseek-v4',
}],
['session.list', {}],
['session.list', {}],
]);
assert.ok(calls.filter(([type]) => type === 'ensureRunning').every(([, value]) => value === options));
});
test('HarnessClient rejects malformed model and run-state responses', async () => {
const { client, responses } = modelClient();
responses.set('llm.models', { groups: null, failures: [] });
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
responses.set('session.models', {
...CATALOG,
current: { provider: 'deepseek-official' },
routable: true,
});
await assert.rejects(
client.getSessionModels('session-one'),
/invalid response for session\.models/,
);
responses.set('session.selectModel', { selected: { provider: '', model: 'bad' } });
await assert.rejects(
client.selectSessionModel('session-one', { provider: 'p', model: 'm' }),
/invalid response for session\.selectModel/,
);
responses.set('session.list', { items: [{ sessionId: 'session-one', running: 'yes' }] });
await assert.rejects(client.isSessionRunning('session-one'), /invalid response for session\.list/);
});
test('selectSessionModel holds the maintenance executor across its RPC', async () => {
const maintenanceController = new AbortController();
const stages = [];
const { calls, client } = modelClient({
sessionMaintenanceExecutor({ sessionId, operation }) {
stages.push(['maintenance', sessionId]);
const result = operation(maintenanceController.signal);
stages.push(['operation-started']);
return result;
},
});
const callerController = new AbortController();
await client.selectSessionModel('session-one', {
provider: 'deepseek-official', model: 'deepseek-v4',
}, { signal: callerController.signal });
assert.deepEqual(stages, [
['maintenance', 'session-one'],
['operation-started'],
]);
const rpcOptions = calls.find((entry) => entry[0] === 'rpc' && entry[1] === 'session.selectModel')[4];
assert.notEqual(rpcOptions.signal, callerController.signal);
assert.equal(rpcOptions.signal.aborted, false);
maintenanceController.abort();
assert.equal(rpcOptions.signal.aborted, true, 'maintenance cancellation must abort the RPC');
});
function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {}) {
const id = sessionId ?? `session-${Math.random()}`;
const calls = [];
const admitted = deferred();
let promptRpcId = null;
let ended = initialEnd;
let answer = initialEnd ? 'already complete' : '';
let endReason = initialEnd ? 'completed' : null;
let historyFailure = null;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3982',
workspace: '/tmp/workspace',
...(controlExecutor ? { controlExecutor } : {}),
});
client.ensureRunning = async () => true;
const history = () => {
if (!promptRpcId) return { events: [] };
const events = [
{ event: { seq: 1, type: 'turn/start', data: { turn: 7 } } },
{ event: {
seq: 2,
type: 'user/message',
data: { turn: 7, source: { rpcId: promptRpcId } },
} },
];
if (answer) {
events.push({ event: {
seq: 3,
type: 'assistant/message',
data: {
turn: 7,
message: { content: [{ type: 'text', text: answer }] },
},
} });
}
if (ended) {
events.push({ event: {
seq: 4,
type: 'turn/end',
data: { turn: 7, reason: endReason },
} });
}
return { events };
};
client.rpc = async (method, payload, _timeoutMs, options) => {
calls.push({ method, payload, options });
if (method === 'session.history') {
if (historyFailure) throw historyFailure;
return history();
}
if (method === 'session.prompt' && payload.mode === 'queue') {
promptRpcId = options.rpcId;
admitted.resolve();
return {};
}
if (method === 'session.prompt' && payload.mode === 'steer') return {};
if (method === 'session.cancel') return {};
throw new Error(`Unexpected RPC ${method}`);
};
return {
id,
calls,
client,
admitted: admitted.promise,
setText(text) { answer = text; },
failHistory(error = new Error('history unavailable')) { historyFailure = error; },
finish({ text = '', reason = 'cancelled' } = {}) {
answer = text;
endReason = reason;
ended = true;
},
};
}
test('control methods require exact owner identity, key, and Session before any RPC', async () => {
const turn = controlledTurn();
const owner = {};
const control = { owner, key: 'direct:one' };
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
void asking.catch(() => undefined);
await turn.admitted;
for (const wrong of [
{ owner: {}, key: 'direct:one' },
{ owner, key: 'direct:two' },
null,
]) {
const before = turn.calls.length;
assert.equal(await turn.client.hasActiveTurn(turn.id, wrong), false);
assert.equal(await turn.client.stopActiveTurn(turn.id, wrong), false);
assert.equal(await turn.client.steerActiveTurn(turn.id, 'do more', wrong), false);
assert.equal(turn.calls.length, before, 'unowned controls must not observe or mutate Session');
}
const beforeOtherSession = turn.calls.length;
assert.equal(await turn.client.stopActiveTurn('different-session', control), false);
assert.equal(turn.calls.length, beforeOtherSession);
assert.equal(await turn.client.hasActiveTurn(turn.id, control), true);
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
const cancelCount = () => turn.calls.filter(({ method }) => method === 'session.cancel').length;
assert.equal(cancelCount(), 1);
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true, 'stop is idempotent');
assert.equal(cancelCount(), 1, 'an idempotent stop must not issue a second cancel RPC');
turn.finish();
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
assert.deepEqual(
turn.calls.find(({ method }) => method === 'session.cancel')?.payload,
{ sessionId: turn.id, keepInbox: true },
);
});
test('a stopped turn returns partial text instead of a generic failure', async () => {
const turn = controlledTurn();
const control = { owner: {}, key: 'direct:partial' };
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
await turn.admitted;
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
turn.finish({ text: 'partial result' });
assert.equal(await asking, 'partial result');
});
test('accepted stop converts later polling failures to turn-stopped and preserves streamed text', async () => {
const withoutText = controlledTurn();
const firstControl = { owner: {}, key: 'direct:poll-error' };
const firstAsk = withoutText.client.ask(withoutText.id, 'work', {
control: firstControl, timeoutMs: 2_000,
});
await withoutText.admitted;
assert.equal(await withoutText.client.stopActiveTurn(withoutText.id, firstControl), true);
withoutText.failHistory();
await assert.rejects(firstAsk, (error) => error?.code === 'turn-stopped');
const withText = controlledTurn();
const secondControl = { owner: {}, key: 'direct:partial-poll-error' };
const updateSeen = deferred();
const secondAsk = withText.client.ask(withText.id, 'work', {
control: secondControl,
timeoutMs: 2_000,
onUpdate(update) {
if (update.type === 'text') updateSeen.resolve();
},
});
await withText.admitted;
withText.setText('partial before transport failure');
await updateSeen.promise;
assert.equal(await withText.client.stopActiveTurn(withText.id, secondControl), true);
withText.failHistory();
assert.equal(await secondAsk, 'partial before transport failure');
});
test('in-process control executor receives exact ownership and suppresses control RPCs', async () => {
const executions = [];
const turn = controlledTurn({
controlExecutor(request) {
executions.push(request);
return true;
},
});
const control = { owner: {}, key: 'direct:executor' };
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
await turn.admitted;
assert.equal(await turn.client.steerActiveTurn(turn.id, 'stay in this turn', control), true);
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
assert.deepEqual(executions.map(({ sessionId, expectedTurn, action, text }) => ({
sessionId, expectedTurn, action, text,
})), [
{ sessionId: turn.id, expectedTurn: 7, action: 'steer', text: 'stay in this turn' },
{ sessionId: turn.id, expectedTurn: 7, action: 'stop', text: undefined },
]);
assert.ok(executions.every(({ promptRpcId }) => typeof promptRpcId === 'string' && promptRpcId));
assert.equal(turn.calls.some(({ method, payload }) => (
method === 'session.cancel' || (method === 'session.prompt' && payload.mode === 'steer')
)), false);
turn.finish();
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
});
test('a refused in-process control action never falls through to HTTP', async () => {
const turn = controlledTurn({ controlExecutor: () => false });
const control = { owner: {}, key: 'direct:executor-race' };
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
await turn.admitted;
assert.equal(await turn.client.steerActiveTurn(turn.id, 'must not wake', control), false);
assert.equal(await turn.client.stopActiveTurn(turn.id, control), false);
assert.equal(turn.calls.some(({ method, payload }) => (
method === 'session.cancel' || (method === 'session.prompt' && payload.mode === 'steer')
)), false);
turn.finish({ text: 'normal completion', reason: 'completed' });
assert.equal(await asking, 'normal completion');
});
test('steer uses mode steer only while the exact owned turn is still active', async () => {
const turn = controlledTurn();
const control = { owner: {}, key: 'direct:steer' };
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
await turn.admitted;
assert.equal(await turn.client.steerActiveTurn(
turn.id,
'first line\nsecond line',
control,
), true);
const steer = turn.calls.find(({ method, payload }) => (
method === 'session.prompt' && payload.mode === 'steer'
));
assert.deepEqual(steer.payload, {
sessionId: turn.id,
mode: 'steer',
content: [{ type: 'text', text: 'first line\nsecond line' }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
turn.finish({ text: 'done', reason: 'completed' });
assert.equal(await asking, 'done');
});
test('steer refuses a turn that ended during its ownership refresh', async () => {
const turn = controlledTurn({ initialEnd: true });
const control = { owner: {}, key: 'direct:ended' };
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
await turn.admitted;
assert.equal(await turn.client.steerActiveTurn(turn.id, 'must not wake', control), false);
assert.equal(turn.calls.some(({ method, payload }) => (
method === 'session.prompt' && payload.mode === 'steer'
)), false);
assert.equal(await asking, 'already complete');
});
function scopedHarnessFixture() {
const calls = [];
let generation = 1;
let present = true;
const workspaces = {
incarnationFor() { return 'incarnation-one'; },
has() { return present; },
generationFor() { return generation; },
workspaceFor() { return '/tmp/workspace'; },
};
const harness = {
async listModels(...args) {
calls.push(['listModels', ...args]);
return CATALOG;
},
async getSessionModels(...args) {
calls.push(['getSessionModels', ...args]);
return { ...CATALOG, current: { provider: 'p', model: 'm' }, routable: true };
},
async selectSessionModel(...args) {
calls.push(['selectSessionModel', ...args]);
return { selected: args[1] };
},
async isSessionRunning(...args) {
calls.push(['isSessionRunning', ...args]);
return true;
},
async hasActiveTurn(...args) {
calls.push(['hasActiveTurn', ...args]);
return true;
},
async stopActiveTurn(...args) {
calls.push(['stopActiveTurn', ...args]);
return true;
},
async steerActiveTurn(...args) {
calls.push(['steerActiveTurn', ...args]);
return true;
},
};
const scope = createBotWorkspaceScope(harness, {
botId: 'bot-one', workspaces, state: {},
});
return {
calls,
harness,
scope,
advanceGeneration() { generation += 1; },
removeBot() { present = false; },
};
}
test('workspace session handles forward every model and control API through one generation fence', async () => {
const fixture = scopedHarnessFixture();
const session = fixture.scope.harness.workspaceSession('session-one');
const signal = new AbortController().signal;
const options = { signal };
const control = { owner: {}, key: 'direct:one' };
await session.models(options);
await session.selectModel({ provider: 'p', model: 'm' }, options);
assert.equal(await session.isRunning(options), true);
assert.equal(await session.hasActiveTurn(control, options), true);
assert.equal(await session.stopActiveTurn(control, options), true);
assert.equal(await session.steerActiveTurn('more', control, options), true);
assert.deepEqual(fixture.calls, [
['getSessionModels', 'session-one', options],
['selectSessionModel', 'session-one', { provider: 'p', model: 'm' }, options],
['isSessionRunning', 'session-one', options],
['hasActiveTurn', 'session-one', control, options],
['stopActiveTurn', 'session-one', control, options],
['steerActiveTurn', 'session-one', 'more', control, options],
]);
const methods = [
['models', [options]],
['selectModel', [{ provider: 'p', model: 'm' }, options]],
['isRunning', [options]],
['hasActiveTurn', [control, options]],
['stopActiveTurn', [control, options]],
['steerActiveTurn', ['more', control, options]],
];
for (const [method, args] of methods) {
const stale = fixture.scope.harness.workspaceSession(`stale-${method}`);
fixture.advanceGeneration();
const before = fixture.calls.length;
await assert.rejects(
stale[method](...args),
(error) => error?.code === WORKSPACE_SESSION_STALE,
method,
);
assert.equal(fixture.calls.length, before, `${method} crossed a stale generation`);
}
});
test('workspace session and listModels re-check their scope after an in-flight RPC', async () => {
const fixture = scopedHarnessFixture();
const modelGate = deferred();
fixture.harness.getSessionModels = async () => modelGate.promise;
const session = fixture.scope.harness.workspaceSession('session-race');
const listing = session.models();
fixture.advanceGeneration();
modelGate.resolve({ ...CATALOG, current: { provider: 'p', model: 'm' }, routable: true });
await assert.rejects(listing, (error) => error?.code === WORKSPACE_SESSION_STALE);
const listGate = deferred();
fixture.harness.listModels = async () => listGate.promise;
const globalListing = fixture.scope.harness.listModels();
fixture.removeBot();
listGate.resolve(CATALOG);
await assert.rejects(globalListing, (error) => error?.code === 'workspace-bot-not-found');
});

View file

@ -27,6 +27,20 @@ async function eventually(predicate, timeoutMs = 1_000) {
assert.fail('condition was not met before timeout');
}
async function within(promise, timeoutMs, messageText) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(messageText)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
function stateFixture(initialSessions = {}) {
const sessions = new Map(Object.entries(initialSessions));
const seen = new Set();
@ -150,6 +164,125 @@ test('all four shared text channels execute /compact outside the model prompt pa
}
});
test('all four shared text channels list models locally and advertise all four commands', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const fixture = stateFixture();
const sent = [];
let asks = 0;
let creates = 0;
const bridge = new Bridge({
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
harness: {
listModels: async () => ({
groups: [{
id: `${name}-provider`,
name: `${name} Provider`,
models: [{ id: 'model-one', name: 'Model One' }],
}],
failures: [],
}),
createSession: async () => { creates += 1; return `${name}-session`; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
});
await bridge.accept(message(`models-${name}`, '/models'));
assert.match(sent.at(-1), new RegExp(`${name}-provider/model-one`), name);
assert.equal(asks, 0, `${name} ask`);
assert.equal(creates, 0, `${name} create`);
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
await bridge.accept(message(`help-${name}`, '/help'));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/stop', '/steer']) {
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
}
}
});
test('/stop uses the shared command fast lane without waiting for the running prompt', async () => {
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
const askStarted = deferred();
const releaseAsk = deferred();
const sent = [];
const controls = {};
let promptSettled = false;
const session = {
sessionExists: async () => true,
ask: async (_text, options) => {
controls.prompt = options.control;
askStarted.resolve();
await releaseAsk.promise;
return '原任务完成';
},
stopActiveTurn: async (control) => {
controls.stop = control;
return true;
},
};
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (_target, text) => sent.push(text) },
harness: { workspaceSession: () => session },
});
const prompt = bridge.accept(message('running-prompt', '执行一个长任务'))
.finally(() => { promptSettled = true; });
await askStarted.promise;
await within(
bridge.accept(message('running-stop', '/stop')),
250,
'/stop waited for the ordinary conversation queue',
);
assert.equal(promptSettled, false);
assert.equal(sent.includes('已请求停止当前任务。'), true);
assert.equal(controls.stop.owner, controls.prompt.owner);
assert.equal(controls.stop.key, controls.prompt.key);
releaseAsk.resolve();
await prompt;
assert.equal(sent.at(-1), '原任务完成');
});
test('a stopped shared-channel turn closes an opened stream instead of leaving a processing placeholder', async () => {
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
const finished = [];
let cancelled = 0;
const sent = [];
const stopped = new Error('stopped');
stopped.code = 'turn-stopped';
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: async () => ({
update() {},
async finish(text) { finished.push(text); },
cancel() { cancelled += 1; },
}),
},
harness: {
workspaceSession: () => ({
sessionExists: async () => true,
ask: async () => { throw stopped; },
}),
},
});
await bridge.accept(message('stopped-stream', '执行长任务'));
assert.deepEqual(finished, ['已停止。']);
assert.equal(cancelled, 0);
assert.deepEqual(sent, []);
});
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],

View file

@ -153,6 +153,42 @@ test('Enterprise WeChat executes /compact for the bound Session without promptin
assert.deepEqual(transport.active, []);
});
test('Enterprise WeChat lists models without prompting and help advertises all four commands', async () => {
const store = state();
store.sessionFor = () => null;
const transport = testClient();
let asks = 0;
let creates = 0;
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: () => 'models-stream',
harness: {
listModels: async () => ({
groups: [{
id: 'wecom-provider',
name: 'WeCom Provider',
models: [{ id: 'model-one', name: 'Model One' }],
}],
failures: [],
}),
createSession: async () => { creates += 1; return 'wecom-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
state: store,
});
await bridge.accept(frame({ msgid: 'models-wecom', text: { content: '/models' } }));
assert.match(transport.streamed.at(-1).content, /wecom-provider\/model-one/);
assert.equal(asks, 0);
assert.equal(creates, 0);
await bridge.accept(frame({ msgid: 'help-models-wecom', text: { content: '/help' } }));
const help = transport.streamed.at(-1).content;
for (const command of ['/models', '/model', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
});
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {
const replies = [];
const active = [];

View file

@ -215,6 +215,44 @@ test('Weixin executes /compact for the bound Session without prompting the model
assert.equal(fixture.seen.has('compact-weixin'), true);
});
test('Weixin lists models without prompting and help advertises all four commands', async () => {
const fixture = stateFixture();
const sent = [];
let asks = 0;
let creates = 0;
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
listModels: async () => ({
groups: [{
id: 'weixin-provider',
name: 'Weixin Provider',
models: [{ id: 'model-one', name: 'Model One' }],
}],
failures: [],
}),
createSession: async () => { creates += 1; return 'weixin-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
state: fixture.state,
});
await bridge.accept(message('models-weixin', '/models'));
assert.match(sent.at(-1).text, /weixin-provider\/model-one/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('help-models-weixin', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
});
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {
const sent = [];
const asked = [];

View file

@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isControlCommand,
runControlCommand,
} from '../src/channels/shared/control-command.mjs';
import { HarnessApprovalQueue } from '../src/channels/shared/harness-approval.mjs';
function fixture({ sessionId = 'session-one', stopped = true, steered = true } = {}) {
const calls = [];
const state = {
sessionFor(key) {
calls.push(['sessionFor', key]);
return sessionId;
},
};
const harness = {
workspaceSession(id) {
calls.push(['workspaceSession', id]);
return {
async stopActiveTurn(control, options) {
calls.push(['stopActiveTurn', id, control, options]);
return stopped;
},
async steerActiveTurn(text, control, options) {
calls.push(['steerActiveTurn', id, text, control, options]);
return steered;
},
};
},
};
return { calls, harness, state };
}
test('isControlCommand reserves valid and malformed control command forms', () => {
for (const value of [
'/stop', ' /STOP ', '/stop now', '/steer', '/StEeR do this', '/steer line one\nline two',
]) {
assert.equal(isControlCommand(value), true, value);
}
for (const value of [null, '', 'stop', '/stopping', '/steering', 'hello /stop']) {
assert.equal(isControlCommand(value), false, String(value));
}
});
test('/stop is exact, text-only, and never creates a Session', async () => {
const { calls, harness, state } = fixture();
assert.match((await runControlCommand('/stop later', harness, state, 'direct:one')).message, /用法/);
assert.match((await runControlCommand('/stop', harness, state, 'direct:one', {
hasImages: true,
})).message, /纯文字/);
assert.equal(calls.length, 0);
});
test('/stop controls only the bound active turn and marks accepted cancellation', async () => {
const active = fixture();
const signal = new AbortController().signal;
const owner = {};
const control = { owner, key: 'direct:one' };
const result = await runControlCommand(
'/STOP',
active.harness,
active.state,
'direct:one',
{ signal, pendingInteraction: true, control },
);
assert.deepEqual(result, { message: '已请求停止当前任务。', stopped: true });
assert.deepEqual(active.calls, [
['sessionFor', 'direct:one'],
['workspaceSession', 'session-one'],
['stopActiveTurn', 'session-one', control, { signal }],
]);
const inactive = fixture({ stopped: false });
const missing = await runControlCommand(
'/stop', inactive.harness, inactive.state, 'direct:one', { control },
);
assert.match(missing.message, /没有正在运行/);
assert.equal(missing.stopped, undefined);
});
test('/stop and /steer return friendly no-session messages without creating one', async () => {
const { harness, state, calls } = fixture({ sessionId: null });
assert.match(
(await runControlCommand('/stop', harness, state, 'direct:one')).message,
/没有正在运行/,
);
assert.match(
(await runControlCommand('/steer 补充', harness, state, 'direct:one')).message,
/普通消息/,
);
assert.equal(calls.some(([method]) => method === 'workspaceSession'), false);
});
test('/steer requires text, preserves multiple lines, and rejects images', async () => {
const { harness, state, calls } = fixture();
assert.match((await runControlCommand('/steer', harness, state, 'direct:one')).message, /用法/);
assert.match((await runControlCommand('/steer ', harness, state, 'direct:one')).message, /用法/);
assert.match((await runControlCommand('/steer text', harness, state, 'direct:one', {
hasImages: true,
})).message, /纯文字/);
assert.equal(calls.length, 0);
const control = { owner: {}, key: 'direct:one' };
const result = await runControlCommand(
'/steer first line\nsecond line',
harness,
state,
'direct:one',
{ control },
);
assert.match(result.message, /已提交/);
assert.deepEqual(calls.find(([method]) => method === 'steerActiveTurn'), [
'steerActiveTurn',
'session-one',
'first line\nsecond line',
control,
{},
]);
});
test('/steer never reaches Harness while an interaction is pending', async () => {
const { harness, state, calls } = fixture();
const result = await runControlCommand('/steer continue', harness, state, 'direct:one', {
pendingInteraction: true,
control: { owner: {}, key: 'direct:one' },
});
assert.match(result.message, /等待你的回答或审批/);
assert.equal(calls.length, 0);
});
test('/steer reports a lost active-turn race instead of starting new work', async () => {
const { harness, state, calls } = fixture({ steered: false });
const result = await runControlCommand('/steer continue', harness, state, 'direct:one', {
control: { owner: {}, key: 'direct:one' },
});
assert.match(result.message, /普通消息/);
assert.equal(calls.filter(([method]) => method === 'steerActiveTurn').length, 1);
});
test('HarnessApprovalQueue exposes whether a route has a live approval', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const replies = [];
const interaction = {
kind: 'approval',
rpcId: 'approval-rpc',
sessionId: 'session-one',
toolCall: { callId: 'call-one', name: 'bash', arguments: '{}' },
payload: {
type: 'approval/requested',
sessionId: 'session-one',
approvalId: 'approval-one',
toolName: 'bash',
callId: 'call-one',
},
async respond() {},
};
await queue.handleRequested(interaction, {
key: 'direct:one',
actor: 'user-one',
send: async (text) => replies.push(text),
});
assert.equal(queue.hasPending('direct:one'), true);
assert.equal(queue.hasPending('direct:other'), false);
await queue.closeRoute('direct:one');
assert.equal(queue.hasPending('direct:one'), false);
});

View file

@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHarnessSessionExecutors,
} from '../plugin-src/host/harness-session-coordinator.mjs';
function contextWith(registry) {
return { get: (name) => name === 'agents' ? registry : undefined };
}
function liveAgent(overrides = {}) {
const calls = [];
const agent = {
status: 'running',
session: {
events: [
{ seq: 1, type: 'turn/start', data: { turn: 6 } },
{ seq: 2, type: 'user/message', data: { source: { rpcId: 'older' } } },
{ seq: 3, type: 'turn/end', data: { turn: 6, reason: 'completed' } },
{ seq: 4, type: 'turn/start', data: { turn: 7 } },
{ seq: 5, type: 'user/message', data: { source: { rpcId: 'prompt-owned' } } },
],
},
cancel(...args) { calls.push(['cancel', ...args]); },
inject(...args) { calls.push(['inject', ...args]); },
steer() { throw new Error('agent.steer must never be called'); },
...overrides,
};
return { agent, calls };
}
test('Host control executor validates and mutates the exact open owned turn synchronously', () => {
const { agent, calls } = liveAgent();
const registry = { get: (sessionId) => sessionId === 'session-one' ? agent : undefined };
const { controlExecutor } = createHarnessSessionExecutors(contextWith(registry));
assert.equal(controlExecutor({
sessionId: 'session-one',
expectedTurn: 7,
promptRpcId: 'prompt-owned',
action: 'steer',
text: '先检查日志\n再继续',
}), true);
assert.equal(calls.length, 1, 'inject must finish before the executor returns');
const message = calls[0][1];
assert.equal(calls[0][0], 'inject');
assert.equal(message.role, 'user');
assert.deepEqual(message.content, [{ type: 'text', text: '先检查日志\n再继续' }]);
assert.deepEqual(message.source, { kind: 'user' });
assert.match(message.id, /^[0-9a-f-]{36}$/i);
assert.equal(Object.isFrozen(message), true);
assert.equal(Object.isFrozen(message.content), true);
assert.equal(Object.isFrozen(message.content[0]), true);
assert.equal(controlExecutor({
sessionId: 'session-one',
expectedTurn: 7,
promptRpcId: 'prompt-owned',
action: 'stop',
}), true);
assert.deepEqual(calls[1], ['cancel', { kind: 'user' }, { keepInbox: true }]);
});
test('Host control executor refuses idle, replaced, closed, and foreign turns without waking', () => {
const { agent, calls } = liveAgent();
const { controlExecutor } = createHarnessSessionExecutors(contextWith({ get: () => agent }));
const request = {
sessionId: 'session-one',
expectedTurn: 7,
promptRpcId: 'prompt-owned',
action: 'steer',
text: 'must not wake',
};
assert.equal(controlExecutor({ ...request, promptRpcId: 'foreign' }), false);
assert.equal(controlExecutor({ ...request, expectedTurn: 6 }), false);
agent.status = 'idle';
assert.equal(controlExecutor(request), false);
agent.status = 'running';
agent.session.events.push(
{ seq: 6, type: 'turn/end', data: { turn: 7, reason: 'cancelled' } },
{ seq: 7, type: 'turn/start', data: { turn: 8 } },
{ seq: 8, type: 'user/message', data: { source: { rpcId: 'someone-else' } } },
);
assert.equal(controlExecutor(request), false);
assert.deepEqual(calls, []);
});
test('Host executors preserve HTTP fallback when AgentRegistry or attachment is absent', async () => {
assert.deepEqual(createHarnessSessionExecutors({}), {
controlExecutor: undefined,
sessionMaintenanceExecutor: undefined,
});
assert.deepEqual(createHarnessSessionExecutors({ get() { throw new Error('not injected'); } }), {
controlExecutor: undefined,
sessionMaintenanceExecutor: undefined,
});
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(
contextWith({ get: () => undefined }),
);
assert.equal(controlExecutor({
sessionId: 'cold-session', expectedTurn: 1, promptRpcId: 'rpc', action: 'stop',
}), undefined);
let operated = false;
assert.equal(await sessionMaintenanceExecutor({
sessionId: 'cold-session',
operation: async (signal) => {
operated = true;
assert.equal(signal, undefined);
return 'fallback';
},
}), 'fallback');
assert.equal(operated, true);
});
test('Host maintenance executor claims idle synchronously and reports busy with a stable code', async () => {
const maintenanceController = new AbortController();
let operationStarted = false;
const agent = {
runMaintenance(operation) {
operationStarted = true;
return operation(maintenanceController.signal);
},
};
const { sessionMaintenanceExecutor } = createHarnessSessionExecutors(
contextWith({ get: () => agent }),
);
const value = sessionMaintenanceExecutor({
sessionId: 'session-one',
operation: async (signal) => {
assert.equal(operationStarted, true);
assert.equal(signal, maintenanceController.signal);
return 'selected';
},
});
assert.equal(operationStarted, true, 'maintenance must be claimed in the calling tick');
assert.equal(await value, 'selected');
const busy = new Error('already active');
agent.runMaintenance = () => { throw busy; };
assert.throws(() => sessionMaintenanceExecutor({
sessionId: 'session-one', operation: async () => undefined,
}), (error) => error?.code === 'agent-busy' && error.cause === busy);
const rpcFailure = new Error('model unavailable');
agent.runMaintenance = (operation) => operation(maintenanceController.signal);
await assert.rejects(sessionMaintenanceExecutor({
sessionId: 'session-one', operation: async () => { throw rpcFailure; },
}), (error) => error === rpcFailure, 'async RPC failures must not be mislabeled as busy');
});

413
test/model-command.test.mjs Normal file
View file

@ -0,0 +1,413 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isModelCommand,
runModelCommand,
} from '../src/channels/shared/model-command.mjs';
const CATALOG = Object.freeze({
groups: [
{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
},
{
id: 'openrouter',
name: 'OpenRouter',
models: [{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4' }],
},
],
failures: [],
});
function deferred() {
let resolve;
const promise = new Promise((settle) => { resolve = settle; });
return { promise, resolve };
}
function fixture({
initialSessionId = null,
existing = true,
globalCatalog = CATALOG,
sessionCatalog = {
...CATALOG,
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routable: true,
},
running = false,
activeTurn = false,
selectionError = null,
selectModelHook,
setSessionResult,
} = {}) {
const calls = [];
let boundId = initialSessionId;
const session = (sessionId) => ({
async sessionExists(options) {
calls.push(['sessionExists', sessionId, options]);
return typeof existing === 'function' ? existing(sessionId) : existing;
},
async models(options) {
calls.push(['models', sessionId, options]);
if (sessionCatalog instanceof Error) throw sessionCatalog;
return sessionCatalog;
},
async isRunning(options) {
calls.push(['isRunning', sessionId, options]);
return running;
},
async hasActiveTurn(control, options) {
calls.push(['hasActiveTurn', sessionId, control, options]);
return activeTurn;
},
async selectModel(selection, options) {
calls.push(['selectModel', sessionId, selection, options]);
if (selectModelHook) await selectModelHook({ sessionId, selection, options });
if (selectionError) throw selectionError;
return { selected: selection };
},
});
const state = {
sessionFor(key) {
calls.push(['sessionFor', key]);
return boundId;
},
async setSession(key, sessionId) {
calls.push(['setSession', key, sessionId]);
if (setSessionResult === false) return false;
boundId = sessionId;
return setSessionResult;
},
async clearSession(key) {
calls.push(['clearSession', key]);
boundId = null;
},
};
const harness = {
async listModels(options) {
calls.push(['listModels', options]);
if (globalCatalog instanceof Error) throw globalCatalog;
return globalCatalog;
},
workspaceSession(sessionId) {
calls.push(['workspaceSession', sessionId]);
return session(sessionId);
},
async createSession(options) {
calls.push(['createSession', options]);
return 'session-created';
},
};
return { calls, harness, state, boundId: () => boundId };
}
test('isModelCommand recognizes only /models and /model command prefixes', () => {
for (const command of [
'/models', ' /MODELS ', '/models ignored', '/model', '/MoDeL openai/gpt-5',
]) {
assert.equal(isModelCommand(command), true, command);
}
for (const value of [null, '', 'model', '/modelx', '/modelsx', 'hello /models']) {
assert.equal(isModelCommand(value), false, String(value));
}
});
test('/models lists the global catalog without creating a Session', async () => {
const { calls, harness, state } = fixture();
const signal = new AbortController().signal;
const result = await runModelCommand('/MODELS', harness, state, 'direct:one', { signal });
assert.match(result.message, /DeepSeek/);
assert.match(result.message, /deepseek-official\/deepseek-v4-flash/);
assert.match(result.message, /openrouter\/anthropic\/claude-sonnet-4/);
assert.deepEqual(calls, [
['sessionFor', 'direct:one'],
['listModels', { signal }],
]);
});
test('/models marks the current Session model and contains provider-local failures', async () => {
const sessionCatalog = {
groups: CATALOG.groups,
current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
routable: true,
failures: [{
id: 'private-provider',
name: 'Private Provider',
message: 'https://private.example.invalid failed with secret=abc',
}],
};
const { harness, state } = fixture({ initialSessionId: 'session-one', sessionCatalog });
const result = await runModelCommand('/models', harness, state, 'direct:one');
assert.match(result.message, /deepseek-official\/deepseek-v4-pro(当前)/);
assert.match(result.message, /Private Provider/);
assert.doesNotMatch(result.message, /private\.example|secret=abc/);
});
test('/models validates its no-argument and text-only syntax', async () => {
const { harness, state } = fixture();
assert.match(
(await runModelCommand('/models openai', harness, state, 'direct:one')).message,
/不带参数/,
);
assert.match(
(await runModelCommand('/models', harness, state, 'direct:one', { hasImages: true })).message,
/仅支持纯文字/,
);
});
test('/models splits a long catalog into lossless 1,800-character messages', async () => {
const longCatalog = {
groups: [{
id: 'provider',
name: 'Large Provider',
models: Array.from({ length: 80 }, (_, index) => ({
id: `model-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`,
name: `Model ${index}`,
})),
}],
failures: [],
};
const { harness, state } = fixture({ globalCatalog: longCatalog });
const result = await runModelCommand('/models', harness, state, 'direct:one');
assert.ok(result.messages.length > 1);
assert.ok(result.messages.every((message) => message.length <= 1_800));
assert.equal(result.messages.join(''), result.message);
assert.match(result.message, /provider\/model-079/);
});
test('/model reports current state without creating or selecting', async () => {
const missing = fixture();
const noSession = await runModelCommand('/model', missing.harness, missing.state, 'direct:one');
assert.match(noSession.message, /还没有会话/);
assert.equal(missing.calls.some(([name]) => name === 'createSession'), false);
const existingFixture = fixture({ initialSessionId: 'session-one' });
const existing = await runModelCommand(
'/MODEL',
existingFixture.harness,
existingFixture.state,
'direct:one',
);
assert.match(existing.message, /deepseek-official\/deepseek-v4-flash/);
assert.equal(existingFixture.calls.some(([name]) => name === 'selectModel'), false);
});
test('/model uses an exact catalog ID and preserves slashes inside the model ID', async () => {
const { calls, harness, state } = fixture({ initialSessionId: 'session-one' });
const control = Object.freeze({ route: 'direct:one' });
const result = await runModelCommand(
'/model openrouter/anthropic/claude-sonnet-4',
harness,
state,
'direct:one',
{ control },
);
assert.match(result.message, /openrouter\/anthropic\/claude-sonnet-4/);
assert.deepEqual(calls.find(([name]) => name === 'selectModel'), [
'selectModel',
'session-one',
{ provider: 'openrouter', model: 'anthropic/claude-sonnet-4' },
{},
]);
});
test('/model rejects unknown IDs before creating a Session', async () => {
const { calls, harness, state } = fixture();
const result = await runModelCommand(
'/model DEEPSEEK-OFFICIAL/deepseek-v4-flash',
harness,
state,
'direct:one',
);
assert.match(result.message, /没有找到模型/);
assert.equal(calls.some(([name]) => name === 'createSession'), false);
assert.equal(calls.some(([name]) => name === 'selectModel'), false);
assert.match(
(await runModelCommand('/model missing-slash', harness, state, 'direct:one')).message,
/用法/,
);
});
test('/model creates and selects a blank Session before exposing its binding', async () => {
const { calls, harness, state, boundId } = fixture();
const signal = new AbortController().signal;
const result = await runModelCommand(
'/model deepseek-official/deepseek-v4-pro',
harness,
state,
'direct:one',
{ signal, control: 'control-one' },
);
assert.match(result.message, /模型已切换为/);
assert.equal(boundId(), 'session-created');
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.indexOf('selectModel') < operations.indexOf('setSession'));
assert.deepEqual(calls.find(([name]) => name === 'selectModel'), [
'selectModel',
'session-created',
{ provider: 'deepseek-official', model: 'deepseek-v4-pro' },
{ signal },
]);
});
test('a failed first model selection leaves the conversation unbound', async () => {
const selectionError = new Error('provider unavailable');
selectionError.code = 'model-unavailable';
const { calls, harness, state, boundId } = fixture({ selectionError });
const result = await runModelCommand(
'/model deepseek-official/deepseek-v4-pro',
harness,
state,
'direct:one',
);
assert.match(result.message, /当前不可用|图片/);
assert.equal(boundId(), null);
assert.equal(calls.some(([name]) => name === 'createSession'), true);
assert.equal(calls.some(([name]) => name === 'selectModel'), true);
assert.equal(calls.some(([name]) => name === 'setSession'), false);
});
test('two concurrent first model switches share one created Session', async () => {
const { calls, harness, state, boundId } = fixture();
const results = await Promise.all([
runModelCommand(
'/model deepseek-official/deepseek-v4-pro',
harness,
state,
'direct:one',
),
runModelCommand(
'/model openrouter/anthropic/claude-sonnet-4',
harness,
state,
'direct:one',
),
]);
assert.ok(results.every(({ message }) => /模型已切换为/.test(message)));
assert.equal(boundId(), 'session-created');
assert.equal(calls.filter(([name]) => name === 'createSession').length, 1);
assert.equal(calls.filter(([name]) => name === 'setSession').length, 1);
assert.deepEqual(
calls.filter(([name]) => name === 'selectModel').map(([, sessionId]) => sessionId),
['session-created', 'session-created'],
);
});
test('a concurrent external binding is preserved after selecting an unbound Session', async () => {
const selectionStarted = deferred();
const releaseSelection = deferred();
const fixtureValue = fixture({
selectModelHook: async ({ sessionId }) => {
if (sessionId !== 'session-created') 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-bound-elsewhere');
releaseSelection.resolve();
const result = await switching;
assert.match(result.message, /会话已发生变化.*重试/);
assert.equal(fixtureValue.boundId(), 'session-bound-elsewhere');
assert.equal(fixtureValue.calls.some((call) => (
call[0] === 'setSession' && call[2] === 'session-created'
)), false);
});
test('/model refuses pending interactions and active or running Sessions', async () => {
const pending = fixture({ initialSessionId: 'session-one' });
const pendingResult = await runModelCommand(
'/model deepseek-official/deepseek-v4-pro',
pending.harness,
pending.state,
'direct:one',
{ pendingInteraction: true },
);
assert.match(pendingResult.message, /等待你的回答或审批/);
assert.equal(pending.calls.some(([name]) => name === 'selectModel'), false);
for (const state of [{ running: true }, { activeTurn: true }]) {
const active = fixture({ initialSessionId: 'session-one', ...state });
const result = await runModelCommand(
'/model deepseek-official/deepseek-v4-pro',
active.harness,
active.state,
'direct:one',
{ control: 'owner-one' },
);
assert.match(result.message, /当前任务正在运行/);
assert.equal(active.calls.some(([name]) => name === 'models'), false);
assert.equal(active.calls.some(([name]) => name === 'selectModel'), false);
}
});
test('a missing bound Session is cleared and /models falls back to the global catalog', async () => {
const { calls, harness, state, boundId } = fixture({
initialSessionId: 'session-missing',
existing: false,
});
const result = await runModelCommand('/models', harness, state, 'direct:one');
assert.match(result.message, /deepseek-official\/deepseek-v4-flash/);
assert.equal(boundId(), null);
assert.equal(calls.some(([name]) => name === 'clearSession'), true);
assert.equal(calls.some(([name]) => name === 'listModels'), true);
});
test('model command failures use safe user-facing messages', async () => {
const privateError = new Error('provider leaked API key sk-private');
privateError.code = 'model-unavailable';
const selection = fixture({
initialSessionId: 'session-one',
selectionError: privateError,
});
const failed = await runModelCommand(
'/model deepseek-official/deepseek-v4-pro',
selection.harness,
selection.state,
'direct:one',
);
assert.match(failed.message, /当前不可用|图片/);
assert.doesNotMatch(failed.message, /sk-private/);
const listing = fixture({ globalCatalog: new Error('private endpoint') });
const unavailable = await runModelCommand('/models', listing.harness, listing.state, 'direct:one');
assert.match(unavailable.message, /暂时无法获取模型列表/);
assert.doesNotMatch(unavailable.message, /private endpoint/);
const bad = fixture({ globalCatalog: { groups: null, failures: [] } });
assert.match(
(await runModelCommand('/models', bad.harness, bad.state, 'direct:one')).message,
/暂时无法获取模型列表/,
);
});
test('non-model input is left for ordinary message routing', async () => {
assert.equal(await runModelCommand('hello', {}, {}, 'direct:one'), null);
});

View file

@ -17,6 +17,7 @@ import {
BotWorkspaceStore,
createBotWorkspaceScope,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { runModelCommand } from '../src/channels/shared/model-command.mjs';
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
async function fixture(t) {
@ -45,6 +46,20 @@ function deferred() {
return { promise, resolve };
}
async function within(promise, timeoutMs, message) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
function memoryState(initial = {}) {
let sessions = { ...initial };
let clears = 0;
@ -188,6 +203,90 @@ test('the next message continues the bound Session without creating a new one',
assert.equal(createCalls, 0);
});
test('a first prompt and model switch share one binding without holding the lock during ask', async () => {
const state = memoryState();
const askStarted = deferred();
const releaseAsk = deferred();
const calls = [];
let creations = 0;
const catalog = {
groups: [{
id: 'provider',
name: 'Provider',
models: [{ id: 'model', name: 'Model' }],
}],
failures: [],
};
const harness = {
async createSession() {
creations += 1;
calls.push(['createSession']);
return `session-${creations}`;
},
workspaceSession(sessionId) {
return {
async sessionExists() { return true; },
async ask(text) {
calls.push(['ask', sessionId, text]);
askStarted.resolve();
await releaseAsk.promise;
return 'answer';
},
async isRunning() { return false; },
async hasActiveTurn() { return false; },
async models() {
return {
...catalog,
current: { provider: 'provider', model: 'model' },
routable: true,
};
},
async selectModel(selection) {
calls.push(['selectModel', sessionId, selection]);
return { selected: selection };
},
};
},
async listModels() { return catalog; },
};
const prompting = askInWorkspaceSession({
harness,
state,
key: 'conversation',
text: 'first prompt',
});
const switching = runModelCommand(
'/model provider/model',
harness,
state,
'conversation',
);
await askStarted.promise;
let switchResult;
try {
switchResult = await within(
switching,
500,
'model switching waited for the running ask instead of only the binding transaction',
);
} finally {
releaseAsk.resolve();
}
const promptResult = await prompting;
assert.match(switchResult.message, /模型已切换为/);
assert.deepEqual(promptResult, { sessionId: 'session-1', answer: 'answer' });
assert.equal(creations, 1);
assert.deepEqual(state.snapshot(), { conversation: 'session-1' });
assert.deepEqual(calls, [
['createSession'],
['ask', 'session-1', 'first prompt'],
['selectModel', 'session-1', { provider: 'provider', model: 'model' }],
]);
});
test('workspace sessions forward structured multimodal prompt content unchanged', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();

View file

@ -21,7 +21,10 @@ import {
splitWorkspaceCommandMessage,
} from '../src/channels/shared/workspace-command.mjs';
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
import {
askInWorkspaceSession,
WORKSPACE_SESSION_STALE,
} from '../src/channels/shared/workspace-session.mjs';
import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs';
import { HarnessClient as FeishuHarnessClient } from '../src/channels/feishu/harness-client.mjs';
import { HarnessClient as DingtalkHarnessClient } from '../src/channels/dingtalk/harness-client.mjs';
@ -205,6 +208,69 @@ test('an old session cannot be written back while RPC switches the bot workspace
assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness');
});
test('an old workspace session handle cannot list, select, stop, or steer after a switch', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_session_controls');
const targetCalls = [];
const harness = {
async getSessionModels(...args) { targetCalls.push(['models', ...args]); },
async selectSessionModel(...args) { targetCalls.push(['select', ...args]); },
async stopActiveTurn(...args) { targetCalls.push(['stop', ...args]); },
async steerActiveTurn(...args) { targetCalls.push(['steer', ...args]); },
};
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_session_controls', workspaces, state,
});
const oldSession = scope.harness.workspaceSession('session-old');
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_session_controls' }] }; },
}, { workspaces, stateFor: async () => state });
await controller.updateWorkspace('bot_session_controls', alternateWorkspace);
const control = { owner: {}, key: 'direct:one' };
for (const operation of [
() => oldSession.models(),
() => oldSession.selectModel({ provider: 'provider', model: 'model' }),
() => oldSession.stopActiveTurn(control),
() => oldSession.steerActiveTurn('continue', control),
]) {
await assert.rejects(operation(), (error) => error?.code === WORKSPACE_SESSION_STALE);
}
assert.deepEqual(targetCalls, []);
});
test('a control mutation that already started keeps its result across a workspace switch', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_started_controls');
const started = [];
let release;
const gate = new Promise((resolve) => { release = resolve; });
const harness = {
async stopActiveTurn() { started.push('stop'); await gate; return true; },
async steerActiveTurn() { started.push('steer'); await gate; return true; },
};
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_started_controls', workspaces, state,
});
const session = scope.harness.workspaceSession('session-old');
const control = { owner: {}, key: 'direct:one' };
const stop = session.stopActiveTurn(control);
const steer = session.steerActiveTurn('continue', control);
assert.deepEqual(started, ['stop', 'steer']);
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_started_controls' }] }; },
}, { workspaces, stateFor: async () => state });
await controller.updateWorkspace('bot_started_controls', alternateWorkspace);
release();
assert.deepEqual(await Promise.all([stop, steer]), [true, true]);
});
test('a prompt retries in the new workspace when switching after session creation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();