mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
1523 lines
46 KiB
JavaScript
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]);
|
|
});
|