fix: support legacy and current DSH hosts

This commit is contained in:
xmanrui 2026-08-29 20:22:07 +08:00
parent 72ffd7115c
commit 6b9118fe95
20 changed files with 1279 additions and 259 deletions

View file

@ -98,7 +98,7 @@ dsh plugin --profile web add -w @xmanrui/dsh-im
Restart `dsh web`, refresh the browser, then open **Settings → IM Bot**. The top-level IM Bot entry uses `order: 21` to follow **Agent Presets**, and the Plugins page no longer retains the old entry. Upgrading preserves existing bots, credentials, workspaces, Agent Presets, and Session bindings.
Local `dsh web` and DSH Desktop use the current Host's internal `apiProxy` by default, without a loopback HTTP connection. Desktop's compatibility, extended-window, and advanced modes do not require browser access or LAN access to be enabled. An explicit channel `harnessBaseUrl` still uses the existing HTTP/WebSocket connection; failed internal calls never silently switch to another Host.
Local `dsh web` and DSH Desktop reuse the current Host's internal services by default: legacy Harness releases use `apiProxy`, while current releases automatically use the Typert Gateway plus the Session and Workspace controllers. No Harness address or loopback HTTP connection is required. Desktop's compatibility, extended-window, and advanced modes do not require browser access or LAN access to be enabled. An explicit channel `harnessBaseUrl` is retained only for legacy remote HTTP/WebSocket Harness endpoints; failed internal calls never silently switch to another Host.
To try the latest code before it is published to npm, use the GitHub-source installer instead:

View file

@ -101,7 +101,7 @@ dsh plugin --profile web add -w @xmanrui/dsh-im
重启 `dsh web`、刷新浏览器,然后打开「设置 → IM机器人」。IM机器人使用 `order: 21`,尽量排在一级设置菜单的「Agent 预设」之后;插件页面不再保留旧入口。从旧版升级不会改变已有机器人、凭据、工作区、Agent Preset 或会话绑定。
本机 `dsh web` 和 DSH Desktop 默认通过当前 Host 的内部 `apiProxy` 接口通信,不再绕行本机 HTTP 端口。Desktop 的兼容模式、扩展窗口和增强模式均无需开启“允许在浏览器中打开”或局域网访问。渠道配置中显式设置的 `harnessBaseUrl` 仍使用原有 HTTP/WebSocket 连接;内部调用失败不会自动改连其他 Host。
本机 `dsh web` 和 DSH Desktop 默认直接复用当前 Host 的内部服务:旧版 Harness 使用 `apiProxy`,新版 Harness 自动使用 Typert Gateway、Session Controller 和 Workspace Controller,不需要配置 Harness 地址,也不绕行本机 HTTP 端口。Desktop 的兼容模式、扩展窗口和增强模式均无需开启“允许在浏览器中打开”或局域网访问。渠道配置中显式设置的 `harnessBaseUrl` 仅保留给旧版远程 HTTP/WebSocket Harness;内部调用失败不会自动改连其他 Host。
如需试用尚未发布到 npm 的最新代码,可以改用 GitHub 源安装器:

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installDingtalkRpc } from './rpc.mjs';
export const name = 'dsh-dingtalk-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installDiscordRpc } from './rpc.mjs';
export const name = 'dsh-im-discord-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -3,7 +3,7 @@ import { createProductionController } from './production.mjs';
import { installFeishuRpc } from './rpc.mjs';
export const name = 'dsh-feishu-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
function controllerFrom(ctx, config) {
if (config?.controller) return config.controller;

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installQqRpc } from './rpc.mjs';
export const name = 'dsh-im-qq-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installSlackRpc } from './rpc.mjs';
export const name = 'dsh-im-slack-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) return installSlackRpc(ctx, config.controller, config.rpcAuthority);

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installTelegramRpc } from './rpc.mjs';
export const name = 'dsh-im-telegram-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installWecomRpc } from './rpc.mjs';
export const name = 'dsh-im-wecom-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installWeixinRpc } from './rpc.mjs';
export const name = 'dsh-weixin-host';
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'credentials', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
import { installWhatsappRpc } from './rpc.mjs';
export const name = 'dsh-im-whatsapp-host';
export const inject = ['connection', 'apiProxy', 'typertGateway'];
export const inject = ['connection', 'typertGateway'];
export async function apply(ctx, config = {}) {
if (config?.controller) {

View file

@ -1,13 +1,18 @@
import { modernHarnessApi } from './modern-harness-api.mjs';
function optionalService(ctx, name) {
if (ctx && Object.hasOwn(ctx, name)) return ctx[name];
return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
}
/** Connect to this Host directly unless an external Harness URL was configured. */
export function harnessConnection(ctx, config = {}) {
if (config.harnessBaseUrl !== undefined) {
return { baseUrl: new URL(config.harnessBaseUrl) };
}
if (!ctx?.apiProxy) {
throw new TypeError('dsh-im requires the Host apiProxy service; check that DSH has finished loading its Host services');
}
const apiProxy = optionalService(ctx, 'apiProxy') ?? modernHarnessApi(ctx);
return {
apiProxy: ctx.apiProxy,
apiProxy,
// Cordis child contexts share one root; different Hosts must not share
// ownership of pending questions and approvals.
interactionScope: ctx.root ?? ctx,

View file

@ -16,7 +16,6 @@ export const name = 'dsh-im-host';
export const inject = [
'connection',
'credentials',
'apiProxy',
'typertGateway',
];
@ -55,6 +54,22 @@ export function createImHostPlugin(internals = {}) {
name,
inject,
async apply(ctx, config = {}) {
const activate = async (readyCtx) => {
await activateChannels(readyCtx, config);
};
if (typeof ctx?.inject === 'function') {
const modern = typeof ctx?.typertGateway?.stream === 'function';
await ctx.inject(
modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
activate,
);
return;
}
await activate(ctx);
},
});
async function activateChannels(ctx, config) {
setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
if (typeof ctx?.inject === 'function') {
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
@ -85,8 +100,7 @@ export function createImHostPlugin(internals = {}) {
if (failures.length === channels.length) {
throw new AggregateError(failures, 'dsh-im failed to activate every channel');
}
},
});
}
}
export async function apply(ctx, config = {}) {

View file

@ -0,0 +1,577 @@
import { randomUUID } from 'node:crypto';
import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
const modernApis = new WeakMap();
function failureOf(error) {
const failure = error?.failure;
if (failure && typeof failure === 'object'
&& typeof failure.code === 'string'
&& typeof failure.message === 'string') {
return {
code: failure.code,
message: failure.message,
details: failure.details && typeof failure.details === 'object' ? failure.details : {},
};
}
if (error?.name === 'AbortError' || error?.name === 'RemoteInvocationCancelled') {
return {
code: 'cancelled',
message: error instanceof Error ? error.message : 'Harness request was cancelled',
details: {},
};
}
return {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
};
}
function rpcResult(request, operation) {
return Promise.resolve().then(operation).then(
(value) => ({ rpcId: request.rpcId, result: { ok: true, value } }),
(error) => ({ rpcId: request.rpcId, result: { ok: false, error: failureOf(error) } }),
);
}
function remoteRequest(namespace, method, args, signal) {
return {
namespace,
method,
args,
...(signal === undefined ? {} : { signal }),
};
}
function questionError(message, code) {
const error = new Error(message);
error.name = 'UserQuestionError';
error.code = code;
return error;
}
function matchesQuestions(value, pending) {
if (!value || typeof value !== 'object'
|| value.sessionId !== pending.sessionId
|| !value.answer || typeof value.answer !== 'object'
|| !Array.isArray(value.answer.answers)
|| value.answer.answers.length !== pending.questions.length) return false;
return value.answer.answers.every((answer, index) => {
const question = pending.questions[index];
if (!answer || typeof answer !== 'object'
|| answer.id !== question.id
|| !Array.isArray(answer.selected)
|| answer.selected.some((label) => typeof label !== 'string')
|| new Set(answer.selected).size !== answer.selected.length
|| (answer.custom !== undefined && typeof answer.custom !== 'string')) return false;
const custom = answer.custom?.trim();
if (custom !== undefined && !custom) return false;
if (question.multiSelect !== true
&& ((custom !== undefined && answer.selected.length > 0) || answer.selected.length > 1)) return false;
const labels = new Set(question.options?.map((option) => option.label) ?? []);
return answer.selected.every((label) => labels.has(label));
});
}
function expandChunkRecord(record) {
if (record?.type === 'event' && record.event && typeof record.event === 'object') {
return [{ event: record.event }];
}
const packed = record?.type === 'chunks' ? record.event : null;
if (!packed || typeof packed !== 'object') {
throw new Error('Harness returned an invalid session history record');
}
const { data } = packed;
const kind = packed.type;
const members = kind === 'chunkrow/tool-call-chunks' ? data?.args : data?.texts;
if (!data || !Array.isArray(members) || members.length === 0
|| members.some((member) => typeof member !== 'string')
|| !Array.isArray(data.dt) || data.dt.length !== members.length - 1) {
throw new Error('Harness returned an invalid packed session chunk');
}
let time = packed.time;
return members.map((member, index) => {
if (index > 0) time += data.dt[index - 1];
let chunk;
if (kind === 'chunkrow/text-chunks') {
chunk = { type: 'text-delta', index: data.index, text: member };
} else if (kind === 'chunkrow/reasoning-chunks') {
chunk = { type: 'reasoning-delta', index: data.index, text: member };
} else if (kind === 'chunkrow/tool-call-chunks') {
chunk = {
type: 'tool-call-delta',
index: data.index,
id: data.id,
...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
argumentsDelta: member,
};
} else {
throw new Error(`Harness returned an unsupported packed session chunk: ${String(kind)}`);
}
return {
event: {
type: 'assistant/chunk',
seq: packed.seq + index,
time,
data: { turn: data.turn, step: data.step, chunk },
},
};
});
}
function historyEntries(records) {
if (!Array.isArray(records)) throw new Error('Harness returned invalid session history records');
return records.flatMap(expandChunkRecord);
}
class MuxSubscription {
#frames = [];
#waiting = null;
#closed = false;
#signal;
#onAbort;
#dispose;
constructor(signal, dispose) {
this.#signal = signal;
this.#dispose = dispose;
this.#onAbort = () => this.close();
signal?.addEventListener('abort', this.#onAbort, { once: true });
if (signal?.aborted) this.close();
}
push(frame) {
if (this.#closed) return;
if (this.#waiting) {
const resolve = this.#waiting;
this.#waiting = null;
resolve({ value: frame, done: false });
} else {
this.#frames.push(frame);
}
}
next() {
if (this.#frames.length > 0) {
return Promise.resolve({ value: this.#frames.shift(), done: false });
}
if (this.#closed) return Promise.resolve({ value: undefined, done: true });
return new Promise((resolve) => { this.#waiting = resolve; });
}
return() {
this.close();
return Promise.resolve({ value: undefined, done: true });
}
close() {
if (this.#closed) return;
this.#closed = true;
this.#signal?.removeEventListener('abort', this.#onAbort);
this.#dispose?.();
this.#dispose = null;
if (this.#waiting) {
const resolve = this.#waiting;
this.#waiting = null;
resolve({ value: undefined, done: true });
}
}
[Symbol.asyncIterator]() {
return this;
}
}
class ModernHarnessApi {
#gateway;
#scope;
#mux = new Set();
#pendingQuestions = new Map();
#pendingApprovals = new Map();
#sessionCursors = new Map();
#disposers = [];
#disposed = false;
constructor(ctx, gateway, scope) {
this.#gateway = gateway;
this.#scope = scope;
this.host = Object.freeze({
describe: (request) => rpcResult(request, () => ({ ready: true, transport: 'typert' })),
});
this.workspace = Object.freeze({
list: (request, signal) => rpcResult(request, () => this.#workspaceList(signal)),
create: (request, signal) => rpcResult(request, () => this.#invoke(
'workspace', 'create', { request: request.payload }, signal,
)),
});
this.sessions = Object.freeze({
list: (request, signal) => rpcResult(request, () => this.#invoke(
'session', 'list', { _request: request.payload }, signal,
)),
create: (request, signal) => rpcResult(request, () => this.#invoke(
'session', 'create', { request: request.payload }, signal,
)),
history: (request, signal) => rpcResult(request, () => this.#history(request.payload, signal)),
prompt: (request, signal) => rpcResult(request, () => this.#invoke(
'session',
'prompt',
{ request: { requestId: request.rpcId, ...request.payload } },
signal,
)),
cancel: (request, signal) => rpcResult(request, () => this.#invoke(
'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
)),
models: (request, signal) => rpcResult(request, () => this.#sessionModels(
request.payload.sessionId, signal,
)),
selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
'session', 'selectModel', { request: request.payload }, signal,
)),
});
this.llm = Object.freeze({
models: (request, signal) => rpcResult(request, async () => {
const catalog = await this.#modelCatalog(signal);
return { groups: catalog.groups, failures: catalog.failures };
}),
});
this.events = Object.freeze({
mux: (_request, signal) => this.#openMux(signal),
});
this.respond = (message) => Promise.resolve(this.#respond(message));
if (typeof ctx?.on === 'function') {
this.#disposers.push(ctx.on('session/event', (session, event) => {
const sessionId = session?.id;
if (typeof sessionId !== 'string' || !event || typeof event !== 'object') return;
if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
this.#broadcast({ type: 'session/event', sessionId, event });
}, { global: true }));
this.#disposers.push(ctx.on(
'approval/request',
(request, next) => this.#requestApproval(request, next),
{ global: true, prepend: true },
));
this.#disposers.push(ctx.on(
'user-questions/request',
(request, next) => this.#requestQuestion(request, next),
{ global: true, prepend: true },
));
}
}
async #invoke(namespace, method, args, signal) {
return this.#gateway.invoke(remoteRequest(namespace, method, args, signal));
}
async #streamFirst(namespace, method, args, signal) {
const controller = new AbortController();
const streamSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
let iterator;
try {
const source = await this.#gateway.stream(remoteRequest(
namespace, method, args, streamSignal,
));
iterator = source[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) throw new Error(`Harness ${namespace}.${method} stream ended before its baseline`);
return first.value;
} finally {
controller.abort(new DOMException('Baseline received', 'AbortError'));
await Promise.resolve(iterator?.return?.()).catch(() => undefined);
}
}
async #workspaceList(signal) {
const frame = await this.#streamFirst('workspace', 'follow', {}, signal);
if (frame?.type !== 'baseline' || !frame.value || typeof frame.value !== 'object') {
throw new Error('Harness workspace.follow returned no baseline');
}
return frame.value;
}
async #sessionSnapshot(sessionId, maxMessages, signal) {
const frame = await this.#streamFirst('session', 'follow', {
request: {
address: { kind: 'session', sessionId },
maxMessages,
},
}, signal);
if (frame?.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
throw new Error('Harness session.follow returned no snapshot');
}
this.#rememberCursor(sessionId, frame.cursor);
return frame;
}
async #history(payload, signal) {
const { sessionId, maxMessages = 50, beforeSeq } = payload;
let cursor = this.#sessionCursors.get(sessionId);
if (cursor === undefined) {
const snapshot = await this.#sessionSnapshot(sessionId, maxMessages, signal);
cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
if (beforeSeq === undefined && cursor === snapshot.cursor) {
return {
events: historyEntries(snapshot.records),
hasMore: snapshot.hasMore === true,
...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
};
}
}
const page = await this.#invoke('session', 'page', {
request: {
address: { kind: 'session', sessionId },
throughSeq: cursor,
maxMessages,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
}, signal);
return { events: historyEntries(page.records), hasMore: page.hasMore === true };
}
#rememberCursor(sessionId, cursor) {
const previous = this.#sessionCursors.get(sessionId);
if (previous === undefined || cursor > previous) this.#sessionCursors.set(sessionId, cursor);
}
#modelCatalog(signal) {
return this.#invoke('session', 'modelCatalog', {}, signal);
}
async #sessionModels(sessionId, signal) {
const [catalog, list] = await Promise.all([
this.#modelCatalog(signal),
this.#invoke('session', 'list', { _request: {} }, signal),
]);
const summary = list?.items?.find((item) => item?.sessionId === sessionId);
if (!summary) {
const error = new Error(`session "${sessionId}" not found`);
error.failure = {
code: 'session-not-found',
message: error.message,
details: { sessionId },
};
throw error;
}
const selection = summary.projections?.values?.modelSelection;
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
return {
current,
routable: catalog.routableProviders.includes(current.provider),
groups: catalog.groups,
failures: catalog.failures,
};
}
#openMux(signal) {
let subscription;
subscription = new MuxSubscription(signal, () => this.#mux.delete(subscription));
this.#mux.add(subscription);
for (const pending of this.#pendingQuestions.values()) subscription.push(this.#questionFrame(pending));
for (const pending of this.#pendingApprovals.values()) subscription.push(this.#approvalFrame(pending));
return subscription;
}
#broadcast(payload, rpcId = randomUUID()) {
const frame = { rpcId, payload };
for (const subscription of this.#mux) subscription.push(frame);
}
#claimableAgent(agent) {
const sessionId = agent?.session?.id ?? agent?.id;
if (typeof sessionId !== 'string' || !agent?.session || !Array.isArray(agent.session.events)) {
return null;
}
return hasActiveHarnessInteractionOwner(
this.#scope,
sessionId,
agent.session.events,
) ? { sessionId, session: agent.session } : null;
}
#questionFrame(pending) {
return {
rpcId: pending.rpcId,
payload: {
type: 'question/requested',
sessionId: pending.sessionId,
questions: pending.questions,
},
};
}
#requestQuestion(request, next) {
const owner = this.#claimableAgent(request?.agent);
if (!owner) return next();
if (request.signal?.aborted) {
return Promise.reject(questionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
));
}
return new Promise((resolve, reject) => {
const pending = {
rpcId: randomUUID(),
sessionId: owner.sessionId,
questions: request.questions,
signal: request.signal,
settle: (outcome, value) => {
if (!this.#pendingQuestions.delete(pending.rpcId)) return;
request.signal?.removeEventListener('abort', onAbort);
this.#broadcast({
type: 'question/resolved',
sessionId: pending.sessionId,
questionRpcId: pending.rpcId,
outcome,
});
if (outcome === 'answered') resolve(value);
else reject(value);
},
};
const onAbort = () => pending.settle('cancelled', questionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
));
this.#pendingQuestions.set(pending.rpcId, pending);
request.signal?.addEventListener('abort', onAbort, { once: true });
this.#broadcast(this.#questionFrame(pending).payload, pending.rpcId);
});
}
#approvalFrame(pending) {
return {
rpcId: pending.rpcId,
payload: {
type: 'approval/requested',
sessionId: pending.sessionId,
approvalId: pending.approvalId,
toolName: pending.toolName,
...(pending.callId === undefined ? {} : { callId: pending.callId }),
...(pending.reason === undefined ? {} : { reason: pending.reason }),
},
};
}
#requestApproval(request, next) {
const owner = this.#claimableAgent(request?.agent);
if (!owner) return next();
if (request.signal?.aborted) return Promise.resolve('cancelled');
const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
const decided = new Set();
let approvalId;
for (let index = owner.session.events.length - 1; index >= 0; index -= 1) {
const event = owner.session.events[index];
if (event.type === 'approval/decided') {
decided.add(event.data?.id);
} else if (event.type === 'approval/asked') {
const id = event.data?.id;
if (!id || decided.has(id) || claimed.has(id)) continue;
if ((request.callId ?? null) !== (event.data?.callId ?? null)) continue;
approvalId = id;
break;
}
}
if (approvalId === undefined) return next();
return new Promise((resolve) => {
const pending = {
rpcId: randomUUID(),
sessionId: owner.sessionId,
approvalId,
toolName: request.toolName,
callId: request.callId,
reason: request.reason,
settle: (outcome) => {
if (!this.#pendingApprovals.delete(pending.rpcId)) return;
request.signal?.removeEventListener('abort', onAbort);
this.#broadcast({
type: 'approval/resolved',
sessionId: pending.sessionId,
approvalId: pending.approvalId,
outcome,
});
resolve(outcome);
},
};
const onAbort = () => pending.settle('cancelled');
this.#pendingApprovals.set(pending.rpcId, pending);
request.signal?.addEventListener('abort', onAbort, { once: true });
this.#broadcast(this.#approvalFrame(pending).payload, pending.rpcId);
});
}
#respond(message) {
const approval = this.#pendingApprovals.get(message?.rpcId);
if (approval) {
const value = message?.result?.value;
if (message?.result?.ok !== true
|| !value || typeof value !== 'object'
|| value.sessionId !== approval.sessionId
|| value.approvalId !== approval.approvalId
|| (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
return { accepted: false, reason: 'bad-response' };
}
approval.settle(value.outcome);
return { accepted: true };
}
const question = this.#pendingQuestions.get(message?.rpcId);
if (!question) return { accepted: false, reason: 'not-pending' };
if (message?.result?.ok !== true) {
if (message?.result?.error?.code !== 'cancelled') {
return { accepted: false, reason: 'bad-response' };
}
question.settle('cancelled', questionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED',
));
return { accepted: true };
}
if (!matchesQuestions(message.result.value, question)) {
return { accepted: false, reason: 'bad-response' };
}
const answer = {
answers: message.result.value.answer.answers.map((item) => ({
id: item.id,
selected: [...item.selected],
...(item.custom === undefined ? {} : { custom: item.custom }),
})),
};
question.settle('answered', answer);
return { accepted: true };
}
dispose() {
if (this.#disposed) return;
this.#disposed = true;
for (const subscription of [...this.#mux]) subscription.close();
for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
'cancelled',
questionError('dsh-im interaction adapter was disposed', 'ASK_ABORTED'),
);
for (const dispose of this.#disposers.splice(0).reverse()) dispose?.();
}
}
/** Create one modern compatibility API per Cordis Host root. */
export function modernHarnessApi(ctx) {
const scope = ctx?.root ?? ctx;
if (!scope || !['object', 'function'].includes(typeof scope)) {
throw new TypeError('dsh-im requires a Cordis Host context');
}
const cached = modernApis.get(scope);
if (cached) return cached;
const gateway = ctx?.typertGateway;
if (!gateway || typeof gateway.invoke !== 'function' || typeof gateway.stream !== 'function') {
throw new TypeError('dsh-im requires the modern Host Typert gateway');
}
const api = new ModernHarnessApi(ctx, gateway, scope);
modernApis.set(scope, api);
if (typeof ctx.effect === 'function') {
ctx.effect(() => () => {
if (modernApis.get(scope) === api) modernApis.delete(scope);
api.dispose();
}, 'dsh-im: modern Harness compatibility API');
}
return api;
}

View file

@ -389,6 +389,24 @@ function consumeInteractionOwnership(ownership, entries) {
}
}
/**
* Decide whether the current Host has a live dsh-im interaction watcher for
* this Session. The modern Harness adapter uses the same ownership registry
* as HarnessClient so it never steals a browser-owned question or approval.
*/
export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) {
if (!scope || !['object', 'function', 'string'].includes(typeof scope)
|| typeof sessionId !== 'string' || !sessionId) return false;
const owners = interactionRegistry(scope).ownerships.get(sessionId);
if (!owners || owners.size === 0) return false;
for (const ownership of owners) consumeInteractionOwnership(ownership, entries);
return [...owners].some((ownership) => (
ownership.active
&& !ownership.completed
&& typeof ownership.reconnect === 'function'
));
}
export class HarnessReplyTracker {
#promptRpcId;
#lastSeq;

View file

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

View file

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

View 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' }],
});
});

View file

@ -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'],