mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
The Harness commands execute descriptor declares agentId, line, and images as required JSON wire fields (strict codec, no acceptsUndefined). The Host command executor only sent agentId and line, so the Typert gateway rejected every IM /compact call with arguments-invalid before the command could run. Send images: [] so /compact works from every IM channel.
217 lines
7.8 KiB
JavaScript
217 lines
7.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
|
|
import { runCompactCommand } from '../src/channels/shared/compact-command.mjs';
|
|
import {
|
|
HarnessClient,
|
|
HarnessRpcError,
|
|
} from '../src/channels/shared/harness-client.mjs';
|
|
import { createHarnessCommandExecutor } from '../plugin-src/host/harness-command-executor.mjs';
|
|
|
|
const PRODUCTION_FILES = [
|
|
'plugin-src/host/channels/feishu/production.mjs',
|
|
'plugin-src/host/channels/weixin/production.mjs',
|
|
'plugin-src/host/channels/dingtalk/production.mjs',
|
|
'plugin-src/host/channels/wecom/production.mjs',
|
|
'plugin-src/host/channels/qq/production.mjs',
|
|
'plugin-src/host/channels/slack/production.mjs',
|
|
'plugin-src/host/channels/shared/production.mjs',
|
|
'plugin-src/host/channels/whatsapp/production.mjs',
|
|
];
|
|
|
|
function state(sessionId = 'session-one') {
|
|
return { sessionFor: () => sessionId };
|
|
}
|
|
|
|
test('compact command validates syntax and requires an existing conversation Session', async () => {
|
|
assert.equal(await runCompactCommand('hello', {}, state(), 'direct:one'), null);
|
|
assert.match(
|
|
(await runCompactCommand('/compact now', {}, state(), 'direct:one')).message,
|
|
/不带参数/,
|
|
);
|
|
assert.match(
|
|
(await runCompactCommand('/COMPACT', {}, state(null), 'direct:one')).message,
|
|
/还没有可压缩的会话/,
|
|
);
|
|
assert.match(
|
|
(await runCompactCommand('/compact', {}, state(), 'direct:one')).message,
|
|
/暂不支持/,
|
|
);
|
|
});
|
|
|
|
test('compact command renders Harness outcomes and never changes the command line', async () => {
|
|
const calls = [];
|
|
const harness = {
|
|
executeCommand: async (sessionId, line, options) => {
|
|
calls.push({ sessionId, line, options });
|
|
return {
|
|
commandId: 'command-one',
|
|
result: { kind: 'error', text: 'Compaction cancelled.' },
|
|
};
|
|
},
|
|
};
|
|
const signal = new AbortController().signal;
|
|
const result = await runCompactCommand(
|
|
' /COMPACT ',
|
|
harness,
|
|
state(),
|
|
'direct:one',
|
|
{ signal },
|
|
);
|
|
|
|
assert.equal(result.message, '上下文压缩已取消。');
|
|
assert.deepEqual(calls, [{
|
|
sessionId: 'session-one',
|
|
line: '/compact',
|
|
options: { signal },
|
|
}]);
|
|
});
|
|
|
|
test('compact command contains unavailable, busy, stale, and invalid command failures', async () => {
|
|
for (const [failure, pattern] of [
|
|
[{ code: 'session-not-found' }, /会话已不存在/],
|
|
[{ code: 'agent-busy' }, /正在生成回复/],
|
|
[{ code: 'workspace-session-stale' }, /状态已发生变化/],
|
|
[{ code: 'commands-unavailable' }, /暂不支持/],
|
|
[new Error('private internal detail'), /压缩失败/],
|
|
]) {
|
|
const result = await runCompactCommand('/compact', {
|
|
executeCommand: async () => { throw failure; },
|
|
}, state(), 'direct:one');
|
|
assert.match(result.message, pattern);
|
|
assert.doesNotMatch(result.message, /private internal detail/);
|
|
}
|
|
|
|
assert.match((await runCompactCommand('/compact', {
|
|
executeCommand: async () => undefined,
|
|
}, state(), 'direct:one')).message, /未注册/);
|
|
assert.match((await runCompactCommand('/compact', {
|
|
executeCommand: async () => ({ commandId: 'bad', result: { kind: 'other' } }),
|
|
}, state(), 'direct:one')).message, /压缩失败/);
|
|
});
|
|
|
|
test('HarnessClient delegates command execution and normalizes Typert lookup failures', async () => {
|
|
const calls = [];
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:1',
|
|
workspace: '/tmp',
|
|
commandExecutor: async (...args) => {
|
|
calls.push(args);
|
|
return { commandId: 'command-one', result: { kind: 'success' } };
|
|
},
|
|
});
|
|
const signal = new AbortController().signal;
|
|
assert.deepEqual(await client.executeCommand('session-one', '/compact', { signal }), {
|
|
commandId: 'command-one',
|
|
result: { kind: 'success' },
|
|
});
|
|
assert.deepEqual(calls, [['session-one', '/compact', { signal }]]);
|
|
|
|
const unavailable = new HarnessClient({ baseUrl: 'http://127.0.0.1:1', workspace: '/tmp' });
|
|
await assert.rejects(unavailable.executeCommand('session-one', '/compact'), {
|
|
code: 'commands-unavailable',
|
|
});
|
|
|
|
const rejected = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:1',
|
|
workspace: '/tmp',
|
|
commandExecutor: async () => {
|
|
const error = new Error('lookup rejected');
|
|
error.failure = { code: 'agent-busy', message: 'busy', details: { reason: 'turn active' } };
|
|
throw error;
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
rejected.executeCommand('session-one', '/compact'),
|
|
(error) => error instanceof HarnessRpcError && error.code === 'agent-busy',
|
|
);
|
|
});
|
|
|
|
test('Host command executor invokes the commands Typert endpoint with the Session identity', async () => {
|
|
const requests = [];
|
|
const executor = createHarnessCommandExecutor({
|
|
typertGateway: { invoke: async (request) => {
|
|
requests.push(request);
|
|
return { commandId: 'command-one', result: { kind: 'success' } };
|
|
} },
|
|
});
|
|
const signal = new AbortController().signal;
|
|
|
|
assert.deepEqual(await executor('session-one', '/compact', { signal }), {
|
|
commandId: 'command-one',
|
|
result: { kind: 'success' },
|
|
});
|
|
assert.deepEqual(requests, [{
|
|
namespace: 'commands',
|
|
method: 'execute',
|
|
// images must ride the wire as an empty array: the Harness commands
|
|
// execute descriptor declares it a required JSON field, and omitting it
|
|
// makes the Typert gateway reject the call with arguments-invalid.
|
|
args: { agentId: 'session-one', line: '/compact', images: [] },
|
|
signal,
|
|
}]);
|
|
assert.equal(createHarnessCommandExecutor({}), undefined);
|
|
assert.throws(() => createHarnessCommandExecutor({}, 'invalid'), /must be a function/);
|
|
});
|
|
|
|
test('all nine production channels receive the Host command executor', async () => {
|
|
for (const path of PRODUCTION_FILES) {
|
|
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
|
assert.match(source, /createHarnessCommandExecutor\(ctx, internals\.commandExecutor\)/, path);
|
|
assert.match(source, /commandExecutor \? \{ commandExecutor \} : \{\}/, path);
|
|
}
|
|
|
|
for (const channel of [
|
|
'feishu', 'weixin', 'dingtalk', 'wecom', 'qq',
|
|
'slack', 'telegram', 'discord', 'whatsapp',
|
|
]) {
|
|
const source = await readFile(
|
|
new URL(`../plugin-src/host/channels/${channel}/index.mjs`, import.meta.url),
|
|
'utf8',
|
|
);
|
|
assert.match(source, /'typertGateway'/, channel);
|
|
}
|
|
});
|
|
|
|
test('all nine production channels use channel presets only as bot creation defaults', async () => {
|
|
for (const path of PRODUCTION_FILES) {
|
|
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
|
assert.doesNotMatch(source, /\bagentPreset:\s*config\.agentPreset/, path);
|
|
const creationDefaults = source.match(
|
|
/workspaces\.ensure\([^;]*\{\s*defaultAgentPreset:\s*config\.agentPreset,?\s*\}\)/g,
|
|
) ?? [];
|
|
assert.equal(
|
|
creationDefaults.length,
|
|
2,
|
|
`${path} must initialize both restored and newly connected bots`,
|
|
);
|
|
assert.match(
|
|
source,
|
|
/const agentPresetCatalog\s*=\s*\(\)\s*=>\s*listAgentPresetCatalog\(ctx\)/,
|
|
`${path} must read the Host preset catalog dynamically`,
|
|
);
|
|
assert.match(
|
|
source,
|
|
/createBotWorkspaceScope\([^;]*agentPresetCatalog[^;]*\)/,
|
|
`${path} must expose the same dynamic catalog to bot commands`,
|
|
);
|
|
assert.match(
|
|
source,
|
|
/createWorkspaceAwareController\([^;]*agentPresetCatalog,?[^;]*\)/,
|
|
`${path} must expose the same dynamic catalog to RPC updates`,
|
|
);
|
|
}
|
|
|
|
for (const channel of ['telegram', 'discord']) {
|
|
const source = await readFile(
|
|
new URL(`../plugin-src/host/channels/${channel}/production.mjs`, import.meta.url),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
source,
|
|
/createTokenProductionController\(ctx, config, internals,/,
|
|
`${channel} must delegate to the shared production assembly`,
|
|
);
|
|
}
|
|
});
|