mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 23:10:46 +08:00
fix: support legacy and current DSH hosts
This commit is contained in:
parent
72ffd7115c
commit
6b9118fe95
20 changed files with 1279 additions and 259 deletions
|
|
@ -20,7 +20,7 @@ function controller() {
|
|||
test('Host exports the DingTalk plugin identity and required services', () => {
|
||||
const plugin = createDingtalkHostPlugin({ controller: controller() });
|
||||
assert.equal(name, 'dsh-dingtalk-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'apiProxy', 'typertGateway']);
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'typertGateway']);
|
||||
assert.equal(plugin.name, name);
|
||||
assert.deepEqual(plugin.inject, inject);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,20 +30,22 @@ test('an explicit Harness URL preserves HTTP transport and never reads the Host
|
|||
assert.throws(() => harnessConnection(ctx, { harnessBaseUrl: 'not a URL' }), TypeError);
|
||||
});
|
||||
|
||||
test('a missing Host apiProxy fails clearly instead of silently falling back to localhost', () => {
|
||||
test('a Host with neither legacy apiProxy nor a modern gateway fails clearly', () => {
|
||||
assert.throws(
|
||||
() => harnessConnection({ webServer: { port: 3080 } }),
|
||||
/requires the Host apiProxy service/,
|
||||
/requires the modern Host Typert gateway/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Host and all IM channel plugins wait for apiProxy rather than a webServer', async () => {
|
||||
assert.ok(hostInject.includes('apiProxy'));
|
||||
test('Host and all IM channel plugins require only services shared by old and new Harness', async () => {
|
||||
assert.equal(hostInject.includes('apiProxy'), false);
|
||||
assert.equal(hostInject.includes('webServer'), false);
|
||||
assert.ok(hostInject.includes('typertGateway'));
|
||||
for (const channel of IM_CHANNELS) {
|
||||
const { inject } = await import(`../plugin-src/host/channels/${channel}/index.mjs`);
|
||||
assert.ok(inject.includes('apiProxy'), channel);
|
||||
assert.equal(inject.includes('apiProxy'), false, channel);
|
||||
assert.equal(inject.includes('webServer'), false, channel);
|
||||
assert.ok(inject.includes('typertGateway'), channel);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
375
test/host-modern-harness-api.test.mjs
Normal file
375
test/host-modern-harness-api.test.mjs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { harnessConnection } from '../plugin-src/host/harness-connection.mjs';
|
||||
import { modernHarnessApi } from '../plugin-src/host/modern-harness-api.mjs';
|
||||
import { HarnessClient, HarnessRpcError } from '../src/channels/shared/harness-client.mjs';
|
||||
|
||||
function asyncValues(...values) {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* values;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeContext(gateway) {
|
||||
const listeners = new Map();
|
||||
const root = {};
|
||||
const ctx = {
|
||||
root,
|
||||
typertGateway: gateway,
|
||||
on(event, listener) {
|
||||
const entries = listeners.get(event) ?? [];
|
||||
entries.push(listener);
|
||||
listeners.set(event, entries);
|
||||
return () => {
|
||||
const index = entries.indexOf(listener);
|
||||
if (index >= 0) entries.splice(index, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
ctx,
|
||||
emit(event, ...args) {
|
||||
for (const listener of [...(listeners.get(event) ?? [])]) listener(...args);
|
||||
},
|
||||
waterfall(event, request, next = () => Promise.resolve('delegated')) {
|
||||
const [listener] = listeners.get(event) ?? [];
|
||||
return listener ? listener(request, next) : next();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('modern adapter maps the narrow legacy API without changing HarnessClient', async () => {
|
||||
const calls = [];
|
||||
let pageCalls = 0;
|
||||
const catalog = {
|
||||
default: { provider: 'deepseek', model: 'chat' },
|
||||
routableProviders: ['deepseek'],
|
||||
groups: [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'chat', name: 'Chat' }] }],
|
||||
failures: [],
|
||||
};
|
||||
const gateway = {
|
||||
async invoke(request) {
|
||||
calls.push(request);
|
||||
const endpoint = `${request.namespace}/${request.method}`;
|
||||
if (endpoint === 'workspace/create') {
|
||||
return { workspace: { workspaceId: 'workspace', path: request.args.request.path }, created: true };
|
||||
}
|
||||
if (endpoint === 'session/list') {
|
||||
return {
|
||||
items: [{
|
||||
sessionId: 'session', running: false, blank: false,
|
||||
projections: {
|
||||
asOfSeq: 8,
|
||||
values: {
|
||||
modelSelection: {
|
||||
lastUsed: { provider: 'deepseek', model: 'chat' },
|
||||
next: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (endpoint === 'session/modelCatalog') return catalog;
|
||||
if (endpoint === 'session/page') {
|
||||
pageCalls += 1;
|
||||
return {
|
||||
records: [{
|
||||
type: 'event',
|
||||
event: { type: 'turn/end', seq: 8, time: 8, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
}],
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
if (endpoint === 'session/prompt') return { accepted: true };
|
||||
if (endpoint === 'session/cancel') return { accepted: true };
|
||||
if (endpoint === 'session/selectModel') return { selected: request.args.request };
|
||||
throw new Error(`unexpected invoke ${endpoint}`);
|
||||
},
|
||||
async stream(request) {
|
||||
calls.push(request);
|
||||
const endpoint = `${request.namespace}/${request.method}`;
|
||||
if (endpoint === 'workspace/follow') {
|
||||
return asyncValues({
|
||||
type: 'baseline',
|
||||
value: {
|
||||
items: [{ workspaceId: 'workspace', path: '/workspace', sessionIds: ['session'] }],
|
||||
archivedSessionIds: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (endpoint === 'session/follow') {
|
||||
return asyncValues({
|
||||
type: 'snapshot',
|
||||
cursor: 7,
|
||||
records: [{
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/text-chunks',
|
||||
seq: 5,
|
||||
time: 100,
|
||||
data: { turn: 1, step: 0, index: 0, texts: ['a', 'b', 'c'], dt: [1, 2] },
|
||||
},
|
||||
}],
|
||||
hasMore: true,
|
||||
projections: { asOfSeq: 7, values: {} },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected stream ${endpoint}`);
|
||||
},
|
||||
};
|
||||
const { ctx } = fakeContext(gateway);
|
||||
const first = harnessConnection(ctx);
|
||||
const second = harnessConnection(ctx);
|
||||
assert.equal(first.apiProxy, second.apiProxy);
|
||||
assert.equal(first.interactionScope, ctx.root);
|
||||
|
||||
const workspace = await first.apiProxy.workspace.list({ rpcId: 'workspace-list', payload: {} });
|
||||
assert.equal(workspace.result.value.items[0].path, '/workspace');
|
||||
|
||||
const history = await first.apiProxy.sessions.history({
|
||||
rpcId: 'history-one', payload: { sessionId: 'session', maxMessages: 50 },
|
||||
});
|
||||
assert.deepEqual(history.result.value.events.map(({ event }) => [
|
||||
event.seq, event.time, event.data.chunk.text,
|
||||
]), [[5, 100, 'a'], [6, 101, 'b'], [7, 103, 'c']]);
|
||||
assert.equal(history.result.value.projections.asOfSeq, 7);
|
||||
|
||||
const nextHistory = await first.apiProxy.sessions.history({
|
||||
rpcId: 'history-two', payload: { sessionId: 'session', maxMessages: 50 },
|
||||
});
|
||||
assert.equal(nextHistory.result.value.events[0].event.seq, 8);
|
||||
assert.equal(pageCalls, 1);
|
||||
|
||||
const models = await first.apiProxy.sessions.models({
|
||||
rpcId: 'models', payload: { sessionId: 'session' },
|
||||
});
|
||||
assert.deepEqual(models.result.value.current, { provider: 'deepseek', model: 'chat' });
|
||||
assert.equal(models.result.value.routable, true);
|
||||
|
||||
await first.apiProxy.sessions.prompt({
|
||||
rpcId: 'prompt-correlation',
|
||||
payload: { sessionId: 'session', mode: 'queue', content: [{ type: 'text', text: 'hi' }] },
|
||||
});
|
||||
const promptCall = calls.find((call) => call.namespace === 'session' && call.method === 'prompt');
|
||||
assert.equal(promptCall.args.request.requestId, 'prompt-correlation');
|
||||
assert.equal(Object.hasOwn(promptCall.args.request, 'rpcId'), false);
|
||||
});
|
||||
|
||||
test('modern adapter preserves Typert business failures as Harness RPC errors', async () => {
|
||||
const gateway = {
|
||||
async invoke() {
|
||||
const error = new Error('missing');
|
||||
error.failure = {
|
||||
code: 'session-not-found',
|
||||
message: 'missing',
|
||||
details: { sessionId: 'missing' },
|
||||
};
|
||||
throw error;
|
||||
},
|
||||
async stream() { throw new Error('unused'); },
|
||||
};
|
||||
const { ctx } = fakeContext(gateway);
|
||||
const client = new HarnessClient({
|
||||
apiProxy: modernHarnessApi(ctx),
|
||||
interactionScope: ctx.root,
|
||||
workspace: '/workspace',
|
||||
});
|
||||
await assert.rejects(
|
||||
() => client.rpc('session.list'),
|
||||
(error) => error instanceof HarnessRpcError
|
||||
&& error.code === 'session-not-found'
|
||||
&& error.details.sessionId === 'missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('modern adapter routes an approval only to the active dsh-im turn', async () => {
|
||||
const session = { id: 'session', events: [] };
|
||||
const eventRecord = (event) => ({ type: 'event', event });
|
||||
let fixture;
|
||||
let turnTask;
|
||||
const append = (event) => {
|
||||
session.events.push(event);
|
||||
fixture.emit('session/event', session, event);
|
||||
};
|
||||
const gateway = {
|
||||
async invoke(request) {
|
||||
const endpoint = `${request.namespace}/${request.method}`;
|
||||
if (endpoint === 'session/page') {
|
||||
return { records: session.events.map(eventRecord), hasMore: false };
|
||||
}
|
||||
if (endpoint === 'session/prompt') {
|
||||
const rpcId = request.args.request.requestId;
|
||||
turnTask = (async () => {
|
||||
append({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } });
|
||||
append({
|
||||
type: 'user/message', seq: 1, time: 1,
|
||||
data: { turn: 1, source: { kind: 'user', rpcId }, message: { content: [] } },
|
||||
});
|
||||
append({
|
||||
type: 'approval/asked', seq: 2, time: 2,
|
||||
data: { id: 'approval-one', toolName: 'bash', callId: 'call-one' },
|
||||
});
|
||||
const outcome = await fixture.waterfall('approval/request', {
|
||||
agent: { id: 'session', session },
|
||||
toolName: 'bash',
|
||||
callId: 'call-one',
|
||||
}, () => Promise.resolve('unavailable'));
|
||||
append({
|
||||
type: 'approval/decided', seq: 3, time: 3,
|
||||
data: { id: 'approval-one', outcome },
|
||||
});
|
||||
append({
|
||||
type: 'assistant/message', seq: 4, time: 4,
|
||||
data: { turn: 1, message: { content: [{ type: 'text', text: 'approved' }] } },
|
||||
});
|
||||
append({
|
||||
type: 'turn/end', seq: 5, time: 5,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
});
|
||||
})();
|
||||
return { accepted: true };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${endpoint}`);
|
||||
},
|
||||
async stream(request) {
|
||||
if (`${request.namespace}/${request.method}` !== 'session/follow') {
|
||||
throw new Error('unexpected stream');
|
||||
}
|
||||
return asyncValues({
|
||||
type: 'snapshot', cursor: -1, records: [], hasMore: false,
|
||||
projections: { asOfSeq: -1, values: {} },
|
||||
});
|
||||
},
|
||||
};
|
||||
fixture = fakeContext(gateway);
|
||||
const connection = harnessConnection(fixture.ctx);
|
||||
const client = new HarnessClient({
|
||||
...connection,
|
||||
workspace: '/workspace',
|
||||
rpcIdPrefix: 'modern-test',
|
||||
logPrefix: 'modern-test',
|
||||
});
|
||||
const interactions = [];
|
||||
const answer = await client.ask('session', 'approve it', {
|
||||
timeoutMs: 5_000,
|
||||
onInteraction: async (interaction) => {
|
||||
interactions.push(interaction);
|
||||
await interaction.respond({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: interaction.sessionId,
|
||||
approvalId: interaction.payload.approvalId,
|
||||
outcome: 'allowed-once',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await turnTask;
|
||||
assert.equal(answer, 'approved');
|
||||
assert.equal(interactions.length, 1);
|
||||
assert.equal(interactions[0].kind, 'approval');
|
||||
assert.equal(session.events[3].data.outcome, 'allowed-once');
|
||||
|
||||
const delegated = await fixture.waterfall('approval/request', {
|
||||
agent: { id: 'other', session: { id: 'other', events: [] } },
|
||||
toolName: 'bash',
|
||||
}, () => Promise.resolve('browser-owned'));
|
||||
assert.equal(delegated, 'browser-owned');
|
||||
});
|
||||
|
||||
test('modern adapter routes structured questions only to the active dsh-im turn', async () => {
|
||||
const session = { id: 'session', events: [] };
|
||||
const eventRecord = (event) => ({ type: 'event', event });
|
||||
let fixture;
|
||||
let turnTask;
|
||||
let structuredAnswer;
|
||||
const append = (event) => {
|
||||
session.events.push(event);
|
||||
fixture.emit('session/event', session, event);
|
||||
};
|
||||
const gateway = {
|
||||
async invoke(request) {
|
||||
const endpoint = `${request.namespace}/${request.method}`;
|
||||
if (endpoint === 'session/page') {
|
||||
return { records: session.events.map(eventRecord), hasMore: false };
|
||||
}
|
||||
if (endpoint === 'session/prompt') {
|
||||
const rpcId = request.args.request.requestId;
|
||||
turnTask = (async () => {
|
||||
append({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } });
|
||||
append({
|
||||
type: 'user/message', seq: 1, time: 1,
|
||||
data: { turn: 1, source: { kind: 'user', rpcId }, message: { content: [] } },
|
||||
});
|
||||
structuredAnswer = await fixture.waterfall('user-questions/request', {
|
||||
agent: { id: 'session', session },
|
||||
questions: [{
|
||||
id: 'environment',
|
||||
question: 'Choose an environment',
|
||||
options: [{ label: 'Test' }, { label: 'Production' }],
|
||||
}],
|
||||
}, () => Promise.reject(new Error('unavailable')));
|
||||
append({
|
||||
type: 'assistant/message', seq: 2, time: 2,
|
||||
data: { turn: 1, message: { content: [{ type: 'text', text: 'question answered' }] } },
|
||||
});
|
||||
append({
|
||||
type: 'turn/end', seq: 3, time: 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
});
|
||||
})();
|
||||
return { accepted: true };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${endpoint}`);
|
||||
},
|
||||
async stream(request) {
|
||||
if (`${request.namespace}/${request.method}` !== 'session/follow') {
|
||||
throw new Error('unexpected stream');
|
||||
}
|
||||
return asyncValues({
|
||||
type: 'snapshot', cursor: -1, records: [], hasMore: false,
|
||||
projections: { asOfSeq: -1, values: {} },
|
||||
});
|
||||
},
|
||||
};
|
||||
fixture = fakeContext(gateway);
|
||||
const client = new HarnessClient({
|
||||
...harnessConnection(fixture.ctx),
|
||||
workspace: '/workspace',
|
||||
rpcIdPrefix: 'modern-question-test',
|
||||
logPrefix: 'modern-question-test',
|
||||
});
|
||||
const interactions = [];
|
||||
const answer = await client.ask('session', 'ask a question', {
|
||||
timeoutMs: 5_000,
|
||||
onInteraction: async (interaction) => {
|
||||
interactions.push(interaction);
|
||||
await interaction.respond({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: interaction.sessionId,
|
||||
answer: { answers: [{ id: 'environment', selected: ['Test'] }] },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await turnTask;
|
||||
assert.equal(answer, 'question answered');
|
||||
assert.equal(interactions.length, 1);
|
||||
assert.equal(interactions[0].kind, 'question');
|
||||
assert.deepEqual(structuredAnswer, {
|
||||
answers: [{ id: 'environment', selected: ['Test'] }],
|
||||
});
|
||||
|
||||
const delegated = await fixture.waterfall('user-questions/request', {
|
||||
agent: { id: 'other', session: { id: 'other', events: [] } },
|
||||
questions: [{ id: 'other', question: 'Browser-owned?' }],
|
||||
}, () => Promise.resolve({ answers: [{ id: 'other', selected: [], custom: 'yes' }] }));
|
||||
assert.deepEqual(delegated, {
|
||||
answers: [{ id: 'other', selected: [], custom: 'yes' }],
|
||||
});
|
||||
});
|
||||
|
|
@ -38,7 +38,6 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
assert.deepEqual(inject, [
|
||||
'connection',
|
||||
'credentials',
|
||||
'apiProxy',
|
||||
'typertGateway',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
|
|
@ -55,6 +54,36 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
]);
|
||||
});
|
||||
|
||||
test('Host waits for apiProxy on legacy Harness and Controllers on modern Harness', async () => {
|
||||
for (const [modern, expected] of [
|
||||
[false, ['apiProxy']],
|
||||
[true, ['sessionController', 'workspaceController']],
|
||||
]) {
|
||||
const injections = [];
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin(Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
applyName,
|
||||
async () => calls.push(channel),
|
||||
])));
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
typertGateway: modern ? { stream() {} } : { invoke() {} },
|
||||
inject(dependencies, callback) {
|
||||
injections.push(dependencies);
|
||||
if (dependencies.includes('tools')) return {};
|
||||
return {
|
||||
then(resolve, reject) {
|
||||
Promise.resolve(callback(ctx)).then(resolve, reject);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await plugin.apply(ctx, {});
|
||||
assert.deepEqual(injections[0], expected);
|
||||
assert.deepEqual(calls, CHANNELS.map(([channel]) => channel));
|
||||
}
|
||||
});
|
||||
|
||||
const CHANNELS = [
|
||||
['feishu', 'applyFeishu'],
|
||||
['weixin', 'applyWeixin'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue