mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +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
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 源安装器:
|
||||
|
||||
|
|
|
|||
416
lib/index.js
416
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
577
plugin-src/host/modern-harness-api.mjs
Normal file
577
plugin-src/host/modern-harness-api.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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