mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 14:33:49 +08:00
feat: add model and turn control commands
This commit is contained in:
parent
fc8f120e13
commit
99ce38e9a4
38 changed files with 3451 additions and 169 deletions
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
483
test/channels/shared/harness-control.test.mjs
Normal file
483
test/channels/shared/harness-control.test.mjs
Normal 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');
|
||||
});
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
168
test/control-command.test.mjs
Normal file
168
test/control-command.test.mjs
Normal 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);
|
||||
});
|
||||
152
test/host-session-coordinator.test.mjs
Normal file
152
test/host-session-coordinator.test.mjs
Normal 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
413
test/model-command.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue