dsh-im-ops/test/channels/dingtalk/harness-client.test.mjs

1523 lines
46 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
HarnessClient,
HarnessInteractionError,
HarnessReplyTracker,
HarnessRpcError,
} from '../../../src/channels/dingtalk/harness-client.mjs';
import {
HarnessClient as FeishuHarnessClient,
} from '../../../src/channels/feishu/harness-client.mjs';
function deferred() {
let resolve;
let reject;
const promise = new Promise((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
async function eventually(predicate, message = 'condition was not met') {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.fail(message);
}
class FakeSocket {
#listeners = new Map();
readyState = 0;
addEventListener(name, listener) {
const listeners = this.#listeners.get(name) ?? new Set();
listeners.add(listener);
this.#listeners.set(name, listeners);
}
removeEventListener(name, listener) {
this.#listeners.get(name)?.delete(listener);
}
open() {
if (this.readyState !== 0) return;
this.readyState = 1;
this.emit('open', {});
}
frame(value) {
this.emit('message', { data: JSON.stringify(value) });
}
close(code = 1000) {
if (this.readyState >= 2) return;
this.readyState = 3;
this.emit('close', { code });
}
emit(name, event) {
for (const listener of [...(this.#listeners.get(name) ?? [])]) listener(event);
}
}
test('HarnessClient lists only absolute workspace paths and forwards request options', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = {
signal: new AbortController().signal,
rpcId: 'dingtalk-workspace-list',
};
let ensuredWith;
let rpcCall;
let response = {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/workspace-one',
title: 'private title',
sessionIds: ['private-session'],
},
{ workspaceId: 'relative', path: 'relative/workspace' },
null,
{ workspaceId: 'workspace-two', path: '/tmp/workspace two' },
],
archivedSessionIds: ['private-archive'],
};
client.ensureRunning = async (received) => { ensuredWith = received; };
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
rpcCall = { method, payload, timeoutMs, options: rpcOptions };
return response;
};
assert.deepEqual(await client.listWorkspaces(options), [
'/tmp/workspace-one',
'/tmp/workspace two',
]);
assert.equal(ensuredWith, options);
assert.deepEqual(rpcCall, {
method: 'workspace.list',
payload: {},
timeoutMs: 30_000,
options,
});
response = null;
assert.deepEqual(await client.listWorkspaces(), []);
});
test('HarnessClient lists sessions by workspace accounting and forwards request options', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
const options = {
signal: new AbortController().signal,
rpcId: 'dingtalk-session-list',
};
const calls = [];
let invalidWorkspaceResponse = false;
let invalidSessionResponse = false;
client.ensureRunning = async (received) => {
calls.push({ method: 'ensureRunning', options: received });
};
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
if (invalidWorkspaceResponse) return null;
return {
items: [
{
workspaceId: 'target',
path: '/tmp/target',
sessionIds: ['session-two', 'session-missing', 'session-one'],
},
{ workspaceId: 'other', path: '/tmp/other', sessionIds: ['cwd-only'] },
],
archivedSessionIds: ['session-missing', 'session-one'],
};
}
assert.equal(method, 'session.list');
if (invalidSessionResponse) return null;
return {
items: [
{
sessionId: 'session-one',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: null } },
},
{
sessionId: 'session-two',
blank: true,
origin: 'subagent',
cwd: '/tmp/different',
projections: { values: { title: 'Second session' } },
},
{
sessionId: 'cwd-only',
blank: false,
cwd: '/tmp/target',
projections: { values: { title: 'Must not leak into target' } },
},
],
};
};
assert.deepEqual(await client.listWorkspaceSessions('/tmp/target', options), {
workspace: '/tmp/target',
sessions: [
{
sessionId: 'session-two',
title: 'Second session',
archived: false,
blank: true,
origin: 'subagent',
summaryAvailable: true,
},
{
sessionId: 'session-missing',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: false,
},
{
sessionId: 'session-one',
title: null,
archived: true,
blank: false,
origin: null,
summaryAvailable: true,
},
],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
]);
calls.length = 0;
assert.deepEqual(await client.listWorkspaceSessions('/tmp/unregistered'), {
workspace: '/tmp/unregistered',
sessions: [],
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options: {} },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options: {} },
]);
invalidWorkspaceResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for workspace\.list/,
);
invalidWorkspaceResponse = false;
invalidSessionResponse = true;
await assert.rejects(
client.listWorkspaceSessions('/tmp/target'),
/invalid response for session\.list/,
);
});
test('HarnessClient adopts one registered ordinary session and forwards request options', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
agentPreset: 'custom-preset',
});
const options = {
signal: new AbortController().signal,
rpcId: 'dingtalk-session-adopt',
};
const calls = [];
client.ensureRunning = async (received) => {
calls.push({ method: 'ensureRunning', options: received });
};
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
return {
items: [
{
workspaceId: 'workspace-target',
path: '/tmp/target',
sessionIds: ['session-other', 'session-target'],
},
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
],
archivedSessionIds: ['session-target'],
};
}
if (method === 'session.list') {
return {
items: [{
sessionId: 'session-target',
projections: { values: { title: 'Existing conversation' } },
}],
};
}
assert.equal(method, 'session.create');
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
};
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
sessionId: 'session-target',
workspace: '/tmp/target',
title: 'Existing conversation',
archived: true,
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
{
method: 'session.create',
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
timeoutMs: 30_000,
options,
},
]);
});
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
let mode = 'unregistered';
let createCalls = 0;
client.ensureRunning = async () => undefined;
client.rpc = async (method) => {
if (method === 'workspace.list') {
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
if (mode === 'unregistered') {
return {
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
archivedSessionIds: [],
};
}
return {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/one',
sessionIds: ['session-target'],
},
...(mode === 'ambiguous' ? [{
workspaceId: 'workspace-two',
path: '/tmp/two',
sessionIds: ['session-target'],
}] : []),
],
archivedSessionIds: [],
};
}
if (method === 'session.list') {
if (mode === 'summary-missing') return { items: [] };
return {
items: [{
sessionId: 'session-target',
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
}],
};
}
createCalls += 1;
return mode === 'bad-create'
? { sessionId: 'different-session' }
: { sessionId: 'session-target' };
};
for (const invalid of [
undefined,
'',
' ',
'session target',
'session\u0000target',
's'.repeat(257),
]) {
await assert.rejects(
client.adoptWorkspaceSession(invalid),
(error) => error?.code === 'session-id-invalid',
);
}
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-not-registered',
);
mode = 'ambiguous';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-workspace-ambiguous',
);
mode = 'summary-missing';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-summary-unavailable',
);
mode = 'subagent';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-subagent-unsupported',
);
assert.equal(createCalls, 0);
mode = 'invalid-workspaces';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for workspace\.list/,
);
mode = 'bad-create';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for session\.create/,
);
assert.equal(createCalls, 1);
});
test('reply tracker associates only the Harness turn created by the DingTalk prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'dingtalk-prompt', afterSeq: 2 });
const update = tracker.consume([
{ event: { seq: 3, type: 'turn/start', data: { turn: 9 } } },
{ event: {
seq: 4,
type: 'user/message',
data: { turn: 9, source: { rpcId: 'dingtalk-prompt' } },
} },
{ event: {
seq: 5,
type: 'assistant/chunk',
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '钉钉' } },
} },
]);
assert.deepEqual(update, { type: 'text', text: '钉钉' });
tracker.consume([
{ event: {
seq: 6,
type: 'assistant/message',
data: { turn: 9, message: { content: [{ type: 'text', text: '钉钉回复完成' }] } },
} },
{ event: { seq: 7, type: 'turn/end', data: { turn: 9, reason: 'completed' } } },
]);
assert.equal(tracker.finished, true);
assert.equal(tracker.answer, '钉钉回复完成');
});
test('reply tracker ignores interleaved turns and events at or before the baseline', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'target', afterSeq: 10 });
tracker.consume([
{ event: { seq: 9, type: 'turn/start', data: { turn: 1 } } },
{ event: { seq: 11, type: 'turn/start', data: { turn: 2 } } },
{ event: { seq: 12, type: 'user/message', data: { turn: 2, source: { rpcId: 'other' } } } },
{ event: {
seq: 13,
type: 'assistant/message',
data: { turn: 2, message: { content: [{ type: 'text', text: 'wrong' }] } },
} },
]);
assert.equal(tracker.answer, '');
assert.equal(tracker.finished, false);
});
test('Harness client validates the RPC envelope and preserves server error codes', async () => {
let request;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
fetchImpl: async (url, options) => {
request = { url: url.toString(), body: JSON.parse(options.body) };
return {
ok: true,
json: async () => ({
type: 'server-response',
rpcId: request.body.rpcId,
result: { ok: false, error: { code: 'session-not-found', message: 'missing' } },
}),
};
},
});
await assert.rejects(
client.rpc('session.history', { sessionId: 'one' }),
(error) => error instanceof HarnessRpcError && error.code === 'session-not-found',
);
assert.equal(request.url, 'http://127.0.0.1:3080/api/session.history');
assert.match(request.body.rpcId, /^dingtalk-/);
});
test('interaction watcher decodes mux requests, filters sessions, and posts exact responses', async () => {
const requests = [];
const opened = deferred();
let socket;
let socketUrl;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080/base',
workspace: '/tmp/workspace',
fetchImpl: async (url, options) => {
requests.push({
url: url.toString(),
method: options.method,
body: JSON.parse(options.body),
});
return { ok: true, json: async () => ({ accepted: true }) };
},
createWebSocket: (url) => {
socketUrl = url;
socket = new FakeSocket();
queueMicrotask(() => socket.open());
return socket;
},
});
const controller = new AbortController();
const interactions = [];
const resolutions = [];
const watching = client.watchInteractions('session-one', {
signal: controller.signal,
onOpen: opened.resolve,
onInteraction: (interaction) => interactions.push(interaction),
onResolved: (resolution) => resolutions.push(resolution),
});
await opened.promise;
socket.frame({
type: 'server-request',
rpcId: 'other-rpc',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'session-other',
questions: [{ id: 'ignored', question: 'ignore me' }],
},
});
socket.frame({
type: 'server-request',
rpcId: 'approval-rpc',
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'session-one',
approvalId: 'approval-one',
toolName: 'bash',
},
});
socket.frame({
type: 'server-request',
rpcId: 'question-rpc',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'session-one',
questions: [{ id: 'language', question: 'Which language?' }],
},
});
socket.frame({
type: 'server-request',
rpcId: 'resolution-envelope',
method: 'question/resolved',
payload: {
type: 'question/resolved',
sessionId: 'session-one',
questionRpcId: 'question-rpc',
outcome: 'answered',
},
});
socket.frame({
type: 'server-request',
rpcId: 'approval-resolution-envelope',
method: 'approval/resolved',
payload: {
type: 'approval/resolved',
sessionId: 'session-one',
approvalId: 'approval-one',
outcome: 'rejected',
},
});
await eventually(() => interactions.length === 2 && resolutions.length === 2);
assert.equal(socketUrl, 'ws://127.0.0.1:3080/api/events.mux');
assert.equal(interactions.length, 2);
assert.deepEqual({
kind: interactions[0].kind,
interactionId: interactions[0].interactionId,
rpcId: interactions[0].rpcId,
sessionId: interactions[0].sessionId,
}, {
kind: 'approval',
interactionId: 'approval-one',
rpcId: 'approval-rpc',
sessionId: 'session-one',
});
assert.deepEqual({
kind: interactions[1].kind,
interactionId: interactions[1].interactionId,
rpcId: interactions[1].rpcId,
sessionId: interactions[1].sessionId,
}, {
kind: 'question',
interactionId: 'question-rpc',
rpcId: 'question-rpc',
sessionId: 'session-one',
});
assert.deepEqual(resolutions.map(({ kind, interactionId, outcome }) => ({
kind,
interactionId,
outcome,
})), [
{
kind: 'question',
interactionId: 'question-rpc',
outcome: 'answered',
},
{
kind: 'approval',
interactionId: 'approval-one',
outcome: 'rejected',
},
]);
assert.equal(requests.length, 0, 'receiving an approval must not approve it automatically');
const result = {
ok: true,
value: {
sessionId: 'session-one',
answer: { answers: [{ id: 'language', selected: [], custom: 'Chinese' }] },
},
};
assert.deepEqual(await interactions[1].respond(result), { accepted: true });
assert.deepEqual(requests, [{
url: 'http://127.0.0.1:3080/api/respond',
method: 'POST',
body: { type: 'client-response', rpcId: 'question-rpc', result },
}]);
controller.abort();
await watching;
assert.equal(socket.readyState, 3);
});
test('interaction watcher reconnects over wss and exposes replayed stable request ids', async () => {
const sockets = [];
const seen = [];
const controller = new AbortController();
const client = new HarnessClient({
baseUrl: 'https://harness.example/nested',
workspace: '/tmp/workspace',
interactionReconnectDelayMs: 0,
createWebSocket: (url) => {
const socket = new FakeSocket();
sockets.push({ socket, url });
queueMicrotask(() => socket.open());
return socket;
},
});
const watching = client.watchInteractions('session-replay', {
signal: controller.signal,
onInteraction: ({ rpcId }) => seen.push(rpcId),
});
await eventually(() => sockets[0]?.socket.readyState === 1);
const frame = {
type: 'server-request',
rpcId: 'stable-question-rpc',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'session-replay',
questions: [{ id: 'replay', question: 'Still pending?' }],
},
};
sockets[0].socket.frame(frame);
sockets[0].socket.close(1006);
await eventually(() => sockets[1]?.socket.readyState === 1, 'watcher did not reconnect');
sockets[1].socket.frame(frame);
await eventually(() => seen.length === 2);
assert.deepEqual(seen, ['stable-question-rpc', 'stable-question-rpc']);
assert.deepEqual(sockets.map(({ url }) => url), [
'wss://harness.example/api/events.mux',
'wss://harness.example/api/events.mux',
]);
controller.abort();
await watching;
});
test('interaction response receipts distinguish protocol rejection from invalid transport data', async () => {
let receipt = { accepted: false, reason: 'not-pending' };
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
fetchImpl: async () => ({ ok: true, json: async () => receipt }),
});
const result = { ok: true, value: { sessionId: 'session-one' } };
await assert.rejects(
client.respondInteraction('question-rpc', result),
(error) => error instanceof HarnessInteractionError
&& error.code === 'interaction-not-pending',
);
receipt = { accepted: false, reason: 'bad-response' };
await assert.rejects(
client.respondInteraction('question-rpc', result),
(error) => error instanceof HarnessInteractionError
&& error.code === 'interaction-bad-response',
);
receipt = { type: 'server-response', accepted: false };
await assert.rejects(
client.respondInteraction('question-rpc', result),
/invalid interaction response receipt/,
);
});
test('ask opens the interaction watcher before prompting and closes it with the turn', async () => {
let socket;
let promptRpcId;
let historyCalls = 0;
const responses = [];
const interactions = [];
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
fetchImpl: async (_url, options) => {
responses.push(JSON.parse(options.body));
return { ok: true, json: async () => ({ accepted: true }) };
},
createWebSocket: () => {
const createdSocket = new FakeSocket();
socket = createdSocket;
queueMicrotask(() => createdSocket.open());
return createdSocket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.history') {
historyCalls += 1;
if (historyCalls === 1) return { events: [] };
return {
events: [
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
{ event: {
seq: 2,
type: 'user/message',
data: { turn: 1, source: { rpcId: promptRpcId } },
} },
{ event: {
seq: 3,
type: 'assistant/message',
data: {
turn: 1,
message: { content: [{ type: 'text', text: '已收到回答' }] },
},
} },
{ event: { seq: 4, type: 'turn/end', data: { turn: 1, reason: 'completed' } } },
],
};
}
assert.equal(method, 'session.prompt');
assert.equal(socket.readyState, 1, 'interaction watcher must open before session.prompt');
assert.equal(payload.sessionId, 'session-one');
promptRpcId = options.rpcId;
socket.frame({
type: 'server-request',
rpcId: 'turn-start-frame',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'session-one',
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
},
});
socket.frame({
type: 'server-request',
rpcId: 'user-message-frame',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'session-one',
event: {
seq: 2,
type: 'user/message',
data: { turn: 1, source: { rpcId: promptRpcId } },
},
},
});
socket.frame({
type: 'server-request',
rpcId: 'question-during-ask',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'session-one',
questions: [{
id: 'ready',
question: 'Ready?',
options: [{ label: 'Yes' }, { label: 'No' }],
}],
},
});
return {};
};
const answer = await client.ask('session-one', '请先提问', {
onInteraction: async (interaction) => {
interactions.push(interaction.rpcId);
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
answer: { answers: [{ id: 'ready', selected: ['Yes'] }] },
},
});
},
});
assert.equal(answer, '已收到回答');
assert.deepEqual(interactions, ['question-during-ask']);
assert.deepEqual(responses, [{
type: 'client-response',
rpcId: 'question-during-ask',
result: {
ok: true,
value: {
sessionId: 'session-one',
answer: { answers: [{ id: 'ready', selected: ['Yes'] }] },
},
},
}]);
assert.equal(socket.readyState, 3);
});
test('approval interactions expose only matching tool calls from the active turn', async () => {
let socket;
let promptRpcId;
let historyCalls = 0;
const interactions = [];
const currentToolCall = {
callId: 'call-current-0',
name: 'bash',
arguments: JSON.stringify({ cmd: 'pwd-0' }),
};
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
createWebSocket: () => {
const createdSocket = new FakeSocket();
socket = createdSocket;
queueMicrotask(() => createdSocket.open());
return createdSocket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.history') {
historyCalls += 1;
// Baseline and mux-open refresh both precede this new prompt. The mux
// session/event frames below establish ownership before approvals arrive.
if (historyCalls <= 2) return { events: [] };
return {
events: [
{ event: { seq: 1, type: 'turn/start', data: { turn: 7 } } },
{ event: {
seq: 2,
type: 'user/message',
data: { turn: 7, source: { rpcId: promptRpcId } },
} },
{ event: {
seq: 3,
type: 'assistant/message',
data: {
turn: 7,
message: { content: [{ type: 'text', text: '审批上下文已捕获' }] },
},
} },
{ event: { seq: 4, type: 'turn/end', data: { turn: 7, reason: 'completed' } } },
],
};
}
assert.equal(method, 'session.prompt');
assert.equal(socket.readyState, 1);
assert.equal(payload.sessionId, 'session-tool-call');
promptRpcId = options.rpcId;
const emitEvent = (rpcId, event) => socket.frame({
type: 'server-request',
rpcId,
method: 'session/event',
payload: { type: 'session/event', sessionId: 'session-tool-call', event },
});
const emitApproval = (rpcId, approvalId, callId) => socket.frame({
type: 'server-request',
rpcId,
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'session-tool-call',
approvalId,
toolName: 'bash',
callId,
reason: '测试工具调用展示',
},
});
emitEvent('turn-start-frame', {
seq: 1,
type: 'turn/start',
data: { turn: 7 },
});
emitEvent('user-message-frame', {
seq: 2,
type: 'user/message',
data: { turn: 7, source: { rpcId: promptRpcId } },
});
emitEvent('other-turn-tool-frame', {
seq: 3,
type: 'tool/call',
data: {
turn: 6,
step: 1,
callId: 'call-other-turn',
name: 'bash',
arguments: JSON.stringify({ cmd: 'whoami' }),
},
});
for (let index = 0; index < 40; index += 1) {
emitEvent(`current-tool-frame-${index}`, {
seq: 4 + index,
type: 'tool/call',
data: {
turn: 7,
step: 1,
callId: `call-current-${index}`,
name: 'bash',
arguments: JSON.stringify({ cmd: `pwd-${index}` }),
},
});
}
emitEvent('code-dispatch-tool-frame', {
seq: 44,
type: 'tool/code-dispatch-start',
data: {
rootCallId: 'run-code-root',
parentCallId: 'run-code-root',
subCallId: 'call-code-1',
name: 'bash',
arguments: { cmd: 'echo code-mode' },
},
});
emitApproval('matching-approval-rpc', 'matching-approval', 'call-current-0');
emitApproval('code-mode-approval-rpc', 'code-mode-approval', 'call-code-1');
emitApproval('missing-approval-rpc', 'missing-approval', 'call-missing');
emitApproval('other-turn-approval-rpc', 'other-turn-approval', 'call-other-turn');
return {};
};
const answer = await client.ask('session-tool-call', '请测试审批上下文', {
onInteraction: (interaction) => interactions.push(interaction),
});
assert.equal(answer, '审批上下文已捕获');
assert.deepEqual(interactions.map((interaction) => ({
approvalId: interaction.interactionId,
hasToolCall: Object.hasOwn(interaction, 'toolCall'),
toolCall: interaction.toolCall,
})), [
{
approvalId: 'matching-approval',
hasToolCall: true,
toolCall: currentToolCall,
},
{
approvalId: 'code-mode-approval',
hasToolCall: true,
toolCall: {
callId: 'call-code-1',
name: 'bash',
arguments: JSON.stringify({ cmd: 'echo code-mode' }),
},
},
{
approvalId: 'missing-approval',
hasToolCall: false,
toolCall: undefined,
},
{
approvalId: 'other-turn-approval',
hasToolCall: false,
toolCall: undefined,
},
]);
assert.equal(socket.readyState, 3);
});
test('reconnect history restores a Code Mode sub-call before replaying its approval', async () => {
const sockets = [];
const controller = new AbortController();
let historyEvents = [];
let received;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
interactionReconnectDelayMs: 0,
createWebSocket: () => {
const socket = new FakeSocket();
const index = sockets.push(socket) - 1;
queueMicrotask(() => {
socket.open();
if (index === 1) {
socket.frame({
type: 'server-request',
rpcId: 'replayed-code-approval-rpc',
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'code-reconnect-session',
approvalId: 'replayed-code-approval',
toolName: 'bash',
callId: 'root-call:code:1',
},
});
}
});
return socket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method, _payload, _timeoutMs, options) => {
if (method === 'session.history') return { events: historyEvents };
assert.equal(method, 'session.prompt');
const events = [
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
{
seq: 2,
type: 'user/message',
data: { turn: 1, source: { rpcId: options.rpcId } },
},
{
seq: 3,
type: 'tool/code-dispatch-start',
data: {
rootCallId: 'root-call',
parentCallId: 'root-call',
subCallId: 'root-call:code:1',
name: 'bash',
arguments: { command: 'echo restored' },
},
},
];
historyEvents = events.map((event) => ({ event }));
sockets[0].frame({
type: 'server-request',
rpcId: 'code-reconnect-start',
method: 'session/event',
payload: { type: 'session/event', sessionId: 'code-reconnect-session', event: events[0] },
});
sockets[0].frame({
type: 'server-request',
rpcId: 'code-reconnect-user',
method: 'session/event',
payload: { type: 'session/event', sessionId: 'code-reconnect-session', event: events[1] },
});
sockets[0].close(1006);
return {};
};
const asking = client.ask('code-reconnect-session', '测试 Code Mode 重连', {
signal: controller.signal,
onInteraction: (interaction) => { received = interaction; },
});
await eventually(() => received !== undefined);
assert.equal(received.recovered, false);
assert.deepEqual(received.toolCall, {
callId: 'root-call:code:1',
name: 'bash',
arguments: JSON.stringify({ command: 'echo restored' }),
});
controller.abort();
await Promise.allSettled([asking]);
});
test('interaction callbacks preserve frame order and watcher shutdown drains them', async () => {
const opened = deferred();
const releaseRequested = deferred();
const order = [];
let socket;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
createWebSocket: () => {
socket = new FakeSocket();
queueMicrotask(() => socket.open());
return socket;
},
});
const controller = new AbortController();
const watching = client.watchInteractions('session-ordered', {
signal: controller.signal,
onOpen: opened.resolve,
onInteraction: async () => {
order.push('requested:start');
await releaseRequested.promise;
order.push('requested:end');
},
onResolved: () => order.push('resolved'),
});
await opened.promise;
socket.frame({
type: 'server-request',
rpcId: 'ordered-question',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'session-ordered',
questions: [{ id: 'ordered', question: 'Wait for delivery' }],
},
});
socket.frame({
type: 'server-request',
rpcId: 'ordered-resolution',
method: 'question/resolved',
payload: {
type: 'question/resolved',
sessionId: 'session-ordered',
questionRpcId: 'ordered-question',
outcome: 'cancelled',
},
});
controller.abort();
await eventually(() => order.length > 0);
assert.deepEqual(order, ['requested:start']);
let settled = false;
void watching.finally(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(settled, false);
releaseRequested.resolve();
await watching;
assert.deepEqual(order, ['requested:start', 'requested:end', 'resolved']);
});
test('concurrent asks on one Harness session receive interactions only for their own active turn', async () => {
const sockets = [];
const prompts = [];
const received = { first: [], second: [] };
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
createWebSocket: () => {
const socket = new FakeSocket();
sockets.push(socket);
queueMicrotask(() => socket.open());
return socket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.history') return { events: [] };
assert.equal(method, 'session.prompt');
prompts.push({ text: payload.content[0].text, rpcId: options.rpcId });
return {};
};
const firstController = new AbortController();
const secondController = new AbortController();
const first = client.ask('shared-session', 'first', {
signal: firstController.signal,
onInteraction: ({ rpcId }) => received.first.push(rpcId),
});
const second = client.ask('shared-session', 'second', {
signal: secondController.signal,
onInteraction: ({ rpcId }) => received.second.push(rpcId),
});
await eventually(() => prompts.length === 2 && sockets.length === 2);
const active = prompts[0];
const frames = [
{
type: 'server-request',
rpcId: 'shared-turn-start',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'shared-session',
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
},
},
{
type: 'server-request',
rpcId: 'shared-user-message',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'shared-session',
event: {
seq: 2,
type: 'user/message',
data: { source: { rpcId: active.rpcId } },
},
},
},
{
type: 'server-request',
rpcId: 'shared-plugin-context',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'shared-session',
event: {
seq: 3,
type: 'user/message',
data: { source: { kind: 'plugin', pluginId: 'time-context' } },
},
},
},
{
type: 'server-request',
rpcId: 'shared-question',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'shared-session',
questions: [{ id: 'owner', question: 'Who owns this turn?' }],
},
},
];
for (const frame of frames) {
for (const socket of sockets) socket.frame(frame);
}
await eventually(() => received.first.length + received.second.length === 1);
assert.deepEqual(received[active.text], ['shared-question']);
assert.deepEqual(received[active.text === 'first' ? 'second' : 'first'], []);
firstController.abort();
secondController.abort();
await Promise.allSettled([first, second]);
});
test('channel-specific clients share interaction ownership for the same Harness origin', async () => {
const sockets = [];
const prompts = [];
const received = { dingtalk: [], feishu: [] };
const makeClient = (Client, channel) => {
const client = new Client({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
createWebSocket: () => {
const socket = new FakeSocket();
sockets.push({ channel, socket });
queueMicrotask(() => socket.open());
return socket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.history') return { events: [] };
assert.equal(method, 'session.prompt');
prompts.push({ channel, rpcId: options.rpcId, text: payload.content[0].text });
return {};
};
return client;
};
const dingtalk = makeClient(HarnessClient, 'dingtalk');
const feishu = makeClient(FeishuHarnessClient, 'feishu');
const dingtalkController = new AbortController();
const feishuController = new AbortController();
const first = dingtalk.ask('cross-channel-session', 'dingtalk prompt', {
signal: dingtalkController.signal,
onInteraction: ({ rpcId }) => received.dingtalk.push(rpcId),
});
await eventually(() => prompts.length === 1);
const second = feishu.ask('cross-channel-session', 'feishu prompt', {
signal: feishuController.signal,
onInteraction: ({ rpcId }) => received.feishu.push(rpcId),
});
await eventually(() => prompts.length === 2 && sockets.length === 2);
const activePrompt = prompts[0];
const frames = [
{
type: 'server-request', rpcId: 'cross-start', method: 'session/event',
payload: {
type: 'session/event', sessionId: 'cross-channel-session',
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
},
},
{
type: 'server-request', rpcId: 'cross-user', method: 'session/event',
payload: {
type: 'session/event', sessionId: 'cross-channel-session',
event: {
seq: 2,
type: 'user/message',
data: { source: { rpcId: activePrompt.rpcId } },
},
},
},
{
type: 'server-request', rpcId: 'cross-question', method: 'question/requested',
payload: {
type: 'question/requested', sessionId: 'cross-channel-session',
questions: [{ id: 'owner', question: 'Which channel owns this turn?' }],
},
},
];
for (const frame of frames) {
for (const { socket } of sockets) socket.frame(frame);
}
await eventually(() => received.dingtalk.length + received.feishu.length === 1);
assert.deepEqual(received, { dingtalk: ['cross-question'], feishu: [] });
dingtalkController.abort();
feishuController.abort();
await Promise.allSettled([first, second]);
});
test('a reconnect revalidates same-session ownership before accepting replayed questions', async () => {
const sockets = [];
const prompts = [];
const received = { first: [], second: [] };
let historyEvents = [];
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
interactionReconnectDelayMs: 0,
createWebSocket: () => {
const socket = new FakeSocket();
sockets.push(socket);
queueMicrotask(() => socket.open());
return socket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.history') return { events: historyEvents };
assert.equal(method, 'session.prompt');
prompts.push({ text: payload.content[0].text, rpcId: options.rpcId });
return {};
};
const firstController = new AbortController();
const secondController = new AbortController();
const first = client.ask('handoff-session', 'first', {
signal: firstController.signal,
onInteraction: ({ rpcId }) => received.first.push(rpcId),
});
await eventually(() => prompts.length === 1 && sockets.length === 1);
const second = client.ask('handoff-session', 'second', {
signal: secondController.signal,
onInteraction: ({ rpcId }) => received.second.push(rpcId),
});
await eventually(() => prompts.length === 2 && sockets.length === 2);
const firstPrompt = prompts.find(({ text }) => text === 'first');
const secondPrompt = prompts.find(({ text }) => text === 'second');
const firstTurnFrames = [
{
type: 'server-request', rpcId: 'handoff-start-one', method: 'session/event',
payload: {
type: 'session/event', sessionId: 'handoff-session',
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
},
},
{
type: 'server-request', rpcId: 'handoff-user-one', method: 'session/event',
payload: {
type: 'session/event', sessionId: 'handoff-session',
event: {
seq: 2,
type: 'user/message',
data: { source: { rpcId: firstPrompt.rpcId } },
},
},
},
{
type: 'server-request', rpcId: 'handoff-plugin-one', method: 'session/event',
payload: {
type: 'session/event', sessionId: 'handoff-session',
event: {
seq: 3,
type: 'user/message',
data: { source: { kind: 'plugin', pluginId: 'time-context' } },
},
},
},
];
for (const frame of firstTurnFrames) {
historyEvents.push({ event: frame.payload.event });
for (const socket of sockets.slice(0, 2)) socket.frame(frame);
}
const firstQuestion = {
type: 'server-request', rpcId: 'handoff-question-one', method: 'question/requested',
payload: {
type: 'question/requested', sessionId: 'handoff-session',
questions: [{ id: 'one', question: 'First turn question' }],
},
};
for (const socket of sockets.slice(0, 2)) socket.frame(firstQuestion);
await eventually(() => received.first.length === 1);
sockets[0].close(1006);
const secondTurnEvents = [
{ seq: 4, type: 'turn/end', data: { turn: 1, reason: 'completed' } },
{ seq: 5, type: 'turn/start', data: { turn: 2 } },
{
seq: 6,
type: 'user/message',
data: { source: { rpcId: secondPrompt.rpcId } },
},
];
historyEvents.push(...secondTurnEvents.map((event) => ({ event })));
for (const event of secondTurnEvents) {
sockets[1].frame({
type: 'server-request',
rpcId: `handoff-event-${event.seq}`,
method: 'session/event',
payload: { type: 'session/event', sessionId: 'handoff-session', event },
});
}
const secondQuestion = {
type: 'server-request', rpcId: 'handoff-question-two', method: 'question/requested',
payload: {
type: 'question/requested', sessionId: 'handoff-session',
questions: [{ id: 'two', question: 'Second turn question' }],
},
};
sockets[1].frame(secondQuestion);
await eventually(() => received.second.length === 1);
await eventually(() => sockets.length >= 3 && sockets[2].readyState === 1);
sockets[2].frame(secondQuestion);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(received, {
first: ['handoff-question-one'],
second: ['handoff-question-two'],
});
firstController.abort();
secondController.abort();
await Promise.allSettled([first, second]);
});
test('a new ask adopts a replayed orphan question before its queued prompt can run', async () => {
const oldHistory = [
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
{
event: {
seq: 2,
type: 'user/message',
data: { turn: 1, source: { rpcId: 'prompt-from-old-runtime' } },
},
},
];
const received = [];
const prompted = deferred();
let socket;
const controller = new AbortController();
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
createWebSocket: () => {
socket = new FakeSocket();
queueMicrotask(() => {
socket.open();
socket.frame({
type: 'server-request',
rpcId: 'orphan-question',
method: 'question/requested',
payload: {
type: 'question/requested',
sessionId: 'orphan-session',
questions: [{ id: 'orphan', question: 'Recovered question' }],
},
});
});
return socket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method) => {
if (method === 'session.history') return { events: oldHistory };
assert.equal(method, 'session.prompt');
prompted.resolve();
return {};
};
const asking = client.ask('orphan-session', 'new queued prompt', {
signal: controller.signal,
onInteraction: ({ rpcId, recovered }) => received.push({ rpcId, recovered }),
});
await prompted.promise;
await eventually(() => received.length === 1);
assert.deepEqual(received, [{ rpcId: 'orphan-question', recovered: true }]);
controller.abort();
await Promise.allSettled([asking]);
});
test('an orphan approval is delivered only as a recovered interaction for safe rejection', async () => {
const oldHistory = [
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
{
event: {
seq: 2,
type: 'user/message',
data: { source: { rpcId: 'approval-prompt-from-old-runtime' } },
},
},
];
const received = [];
const prompted = deferred();
const controller = new AbortController();
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
createWebSocket: () => {
const socket = new FakeSocket();
queueMicrotask(() => {
socket.open();
socket.frame({
type: 'server-request',
rpcId: 'orphan-approval-rpc',
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'orphan-approval-session',
approvalId: 'orphan-approval-id',
toolName: 'bash',
},
});
});
return socket;
},
});
client.ensureRunning = async () => true;
client.rpc = async (method) => {
if (method === 'session.history') return { events: oldHistory };
assert.equal(method, 'session.prompt');
prompted.resolve();
return {};
};
const asking = client.ask('orphan-approval-session', 'new queued prompt', {
signal: controller.signal,
onInteraction: (interaction) => received.push(interaction),
});
await prompted.promise;
await eventually(() => received.length === 1);
assert.equal(received[0].kind, 'approval');
assert.equal(received[0].interactionId, 'orphan-approval-id');
assert.equal(received[0].recovered, true);
assert.equal(Object.hasOwn(received[0], 'toolCall'), false);
controller.abort();
await Promise.allSettled([asking]);
});