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

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