mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
976 lines
31 KiB
JavaScript
976 lines
31 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
HarnessClient,
|
|
HarnessReplyTracker,
|
|
} from '../../../src/channels/feishu/harness-client.mjs';
|
|
import {
|
|
OUTBOUND_ARTIFACT_TOOL,
|
|
createOutboundArtifactTool,
|
|
materializeOutboundArtifact,
|
|
outboundArtifactRegistry,
|
|
releaseOutboundArtifact,
|
|
} from '../../../src/channels/shared/semantic/artifact.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('interaction watcher uses the real Harness wire protocol and leaves approvals fail-closed', async () => {
|
|
const requests = [];
|
|
const opened = deferred();
|
|
let socket;
|
|
let socketUrl;
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080/base',
|
|
workspace: '/tmp/dsh-feishu-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 watching = client.watchInteractions('session-feishu', {
|
|
signal: controller.signal,
|
|
onOpen: opened.resolve,
|
|
onInteraction: (interaction) => interactions.push(interaction),
|
|
});
|
|
await opened.promise;
|
|
|
|
socket.frame({
|
|
type: 'server-request',
|
|
rpcId: 'approval-rpc',
|
|
method: 'approval/requested',
|
|
payload: {
|
|
type: 'approval/requested',
|
|
sessionId: 'session-feishu',
|
|
approvalId: 'approval-one',
|
|
toolName: 'bash',
|
|
},
|
|
});
|
|
socket.frame({
|
|
type: 'server-request',
|
|
rpcId: 'question-rpc',
|
|
method: 'question/requested',
|
|
payload: {
|
|
type: 'question/requested',
|
|
sessionId: 'session-feishu',
|
|
questions: [{
|
|
id: 'environment',
|
|
question: '请选择测试环境',
|
|
options: [{ label: '测试环境' }, { label: '生产环境' }],
|
|
}],
|
|
},
|
|
});
|
|
|
|
await eventually(() => interactions.length === 2);
|
|
assert.equal(socketUrl, 'ws://127.0.0.1:3080/api/events.mux');
|
|
assert.deepEqual(interactions.map((interaction) => ({
|
|
kind: interaction.kind,
|
|
interactionId: interaction.interactionId,
|
|
rpcId: interaction.rpcId,
|
|
sessionId: interaction.sessionId,
|
|
})), [
|
|
{
|
|
kind: 'approval',
|
|
interactionId: 'approval-one',
|
|
rpcId: 'approval-rpc',
|
|
sessionId: 'session-feishu',
|
|
},
|
|
{
|
|
kind: 'question',
|
|
interactionId: 'question-rpc',
|
|
rpcId: 'question-rpc',
|
|
sessionId: 'session-feishu',
|
|
},
|
|
]);
|
|
assert.equal(requests.length, 0, 'receiving an approval must never approve it automatically');
|
|
|
|
const result = {
|
|
ok: true,
|
|
value: {
|
|
sessionId: 'session-feishu',
|
|
answer: { answers: [{ id: 'environment', selected: ['测试环境'] }] },
|
|
},
|
|
};
|
|
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('global event watcher reconnects without resolving until abort', async () => {
|
|
const sockets = [];
|
|
const socketUrls = [];
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080/base',
|
|
workspace: '/tmp/dsh-feishu-workspace',
|
|
interactionReconnectDelayMs: 0,
|
|
createWebSocket: (url) => {
|
|
const socket = new FakeSocket();
|
|
sockets.push(socket);
|
|
socketUrls.push(url);
|
|
queueMicrotask(() => socket.open());
|
|
return socket;
|
|
},
|
|
});
|
|
const controller = new AbortController();
|
|
const events = [];
|
|
let reconnects = 0;
|
|
let settled = false;
|
|
const watching = client.watchHarnessEvents({
|
|
signal: controller.signal,
|
|
onReconnect: () => { reconnects += 1; },
|
|
onSessionEvent: (payload) => events.push(payload),
|
|
});
|
|
watching.finally(() => { settled = true; });
|
|
|
|
await eventually(() => reconnects === 1);
|
|
sockets[0].frame({
|
|
type: 'server-request',
|
|
rpcId: 'event-one',
|
|
method: 'session/event',
|
|
payload: {
|
|
type: 'session/event',
|
|
sessionId: 'session-one',
|
|
event: { type: 'turn/end', seq: 1 },
|
|
},
|
|
});
|
|
sockets[0].frame({
|
|
type: 'server-request',
|
|
rpcId: 'invalid-method',
|
|
method: 'different/method',
|
|
payload: {
|
|
type: 'session/event',
|
|
sessionId: 'ignored',
|
|
event: { type: 'turn/end', seq: 2 },
|
|
},
|
|
});
|
|
await eventually(() => events.length === 1);
|
|
|
|
sockets[0].close();
|
|
await eventually(() => reconnects === 2);
|
|
assert.equal(settled, false, 'a dropped socket must not complete the watcher');
|
|
sockets[1].frame({
|
|
type: 'server-request',
|
|
rpcId: 'event-two',
|
|
method: 'session/event',
|
|
payload: {
|
|
type: 'session/event',
|
|
sessionId: 'session-two',
|
|
event: { type: 'turn/end', seq: 3 },
|
|
},
|
|
});
|
|
await eventually(() => events.length === 2);
|
|
|
|
assert.deepEqual(socketUrls, [
|
|
'ws://127.0.0.1:3080/api/events.mux',
|
|
'ws://127.0.0.1:3080/api/events.mux',
|
|
]);
|
|
assert.deepEqual(events.map(({ sessionId, event }) => [sessionId, event.seq]), [
|
|
['session-one', 1],
|
|
['session-two', 3],
|
|
]);
|
|
controller.abort();
|
|
await watching;
|
|
assert.equal(sockets[1].readyState, 3);
|
|
assert.equal(settled, true);
|
|
});
|
|
|
|
test('HarnessClient lists only absolute workspace paths', async () => {
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/default-workspace',
|
|
});
|
|
const options = { rpcId: 'feishu-workspace-list' };
|
|
const calls = [];
|
|
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 () => { calls.push({ method: 'ensureRunning' }); };
|
|
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
|
|
calls.push({ method, payload, timeoutMs, options: rpcOptions });
|
|
return response;
|
|
};
|
|
|
|
assert.deepEqual(await client.listWorkspaces(options), [
|
|
'/tmp/workspace-one',
|
|
'/tmp/workspace two',
|
|
]);
|
|
assert.deepEqual(calls, [
|
|
{ method: 'ensureRunning' },
|
|
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
|
|
]);
|
|
|
|
response = { items: 'invalid' };
|
|
assert.deepEqual(await client.listWorkspaces(), []);
|
|
});
|
|
|
|
test('HarnessClient lists sessions by workspace accounting in its stored order', async () => {
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/default-workspace',
|
|
});
|
|
const options = { rpcId: 'feishu-session-list' };
|
|
const calls = [];
|
|
let invalidWorkspaceResponse = false;
|
|
let invalidSessionResponse = false;
|
|
client.ensureRunning = async () => { calls.push({ method: 'ensureRunning' }); };
|
|
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: { asOfSeq: -1, values: { title: null } },
|
|
},
|
|
{
|
|
sessionId: 'session-two',
|
|
blank: true,
|
|
origin: 'subagent',
|
|
cwd: '/tmp/different',
|
|
projections: { asOfSeq: 0, 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,
|
|
lastSeq: 0,
|
|
},
|
|
{
|
|
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,
|
|
lastSeq: -1,
|
|
},
|
|
],
|
|
});
|
|
assert.deepEqual(calls, [
|
|
{ method: 'ensureRunning' },
|
|
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
|
|
{ method: 'session.list', payload: {}, timeoutMs: 30000, options },
|
|
]);
|
|
|
|
calls.length = 0;
|
|
assert.deepEqual(await client.listWorkspaceSessions('/tmp/unregistered'), {
|
|
workspace: '/tmp/unregistered',
|
|
sessions: [],
|
|
});
|
|
assert.deepEqual(calls, [
|
|
{ method: 'ensureRunning' },
|
|
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, 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 without changing its preset', 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: 'feishu-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: 30000, options },
|
|
{ method: 'session.list', payload: {}, timeoutMs: 30000, options },
|
|
{
|
|
method: 'session.create',
|
|
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
|
|
timeoutMs: 30000,
|
|
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('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
|
|
const methods = [];
|
|
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
|
const request = JSON.parse(options.body);
|
|
methods.push(request.method);
|
|
const value = request.method === 'workspace.list'
|
|
? { items: [], archivedSessionIds: [] }
|
|
: {
|
|
workspace: {
|
|
workspaceId: 'workspace-new',
|
|
path: '/tmp/dsh-feishu-workspace',
|
|
},
|
|
created: true,
|
|
};
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
type: 'server-response',
|
|
rpcId: request.rpcId,
|
|
result: { ok: true, value },
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/dsh-feishu-workspace',
|
|
agentPreset: 'standard',
|
|
autostart: false,
|
|
dshBin: 'dsh',
|
|
});
|
|
|
|
assert.equal(await client.workspaceId(), 'workspace-new');
|
|
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
|
|
});
|
|
|
|
test('HarnessClient asks do not control file-return tool availability', async () => {
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/dsh-feishu-workspace',
|
|
});
|
|
client.ensureRunning = async () => undefined;
|
|
let promptRpcId;
|
|
let prompted = false;
|
|
const agent = {
|
|
session: {
|
|
header: { id: 'session-artifact-availability', cwd: '/tmp/dsh-feishu-workspace' },
|
|
events: [],
|
|
},
|
|
};
|
|
client.rpc = async (method, _payload, _timeoutMs, options) => {
|
|
if (method === 'session.history' && !prompted) return { events: [] };
|
|
if (method === 'session.prompt') {
|
|
prompted = true;
|
|
promptRpcId = options.rpcId;
|
|
agent.session.events = [
|
|
{ type: 'turn/start', data: { turn: 1 } },
|
|
{ type: 'user/message', data: { turn: 1, source: { rpcId: promptRpcId } } },
|
|
];
|
|
return {};
|
|
}
|
|
return {
|
|
events: [
|
|
{ event: { type: 'turn/start', seq: 1, data: { turn: 1 } } },
|
|
{
|
|
event: {
|
|
type: 'user/message',
|
|
seq: 2,
|
|
data: { turn: 1, source: { rpcId: promptRpcId } },
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
type: 'assistant/message',
|
|
seq: 3,
|
|
data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'done' }] } },
|
|
},
|
|
},
|
|
{ event: { type: 'turn/end', seq: 4, data: { turn: 1, reason: { kind: 'completed' } } } },
|
|
],
|
|
};
|
|
};
|
|
|
|
assert.equal(await client.ask('session-artifact-availability', 'create a file', {
|
|
onArtifact: async () => undefined,
|
|
}), 'done');
|
|
});
|
|
|
|
test('HarnessClient stages inbound files, appends a neutral manifest, and cleans after turn end', async () => {
|
|
const inboundSources = [{ name: '用户报告.bin', load: async () => Buffer.from('bytes') }];
|
|
const ingressCalls = [];
|
|
let cleanupCalls = 0;
|
|
let promptPayload;
|
|
let promptRpcId;
|
|
let prompted = false;
|
|
const stagedFiles = [{
|
|
name: '用户报告.bin',
|
|
path: '.dsh-im/inbound/turn-abc/01-用户报告.bin',
|
|
mediaType: 'application/octet-stream',
|
|
}];
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/must-not-select-the-session-cwd',
|
|
fileIngressExecutor: async (request) => {
|
|
ingressCalls.push(request);
|
|
return {
|
|
files: stagedFiles,
|
|
async cleanup() { cleanupCalls += 1; },
|
|
};
|
|
},
|
|
});
|
|
client.ensureRunning = async () => undefined;
|
|
client.rpc = async (method, payload, _timeoutMs, options) => {
|
|
if (method === 'session.history' && !prompted) return { events: [] };
|
|
if (method === 'session.list') {
|
|
return { items: [{ sessionId: 'session-inbound-files', cwd: '/tmp/exact-session-cwd' }] };
|
|
}
|
|
if (method === 'session.prompt') {
|
|
prompted = true;
|
|
promptPayload = payload;
|
|
promptRpcId = options.rpcId;
|
|
assert.equal(cleanupCalls, 0, 'files remain available while the prompt is running');
|
|
return {};
|
|
}
|
|
return {
|
|
events: [
|
|
{ event: { type: 'turn/start', seq: 1, data: { turn: 3 } } },
|
|
{
|
|
event: {
|
|
type: 'user/message',
|
|
seq: 2,
|
|
data: { turn: 3, source: { rpcId: promptRpcId } },
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
type: 'assistant/message',
|
|
seq: 3,
|
|
data: {
|
|
turn: 3,
|
|
step: 1,
|
|
message: { content: [{ type: 'text', text: '已读取附件。' }] },
|
|
},
|
|
},
|
|
},
|
|
{ event: { type: 'turn/end', seq: 4, data: { turn: 3, reason: { kind: 'completed' } } } },
|
|
],
|
|
};
|
|
};
|
|
|
|
assert.equal(await client.ask('session-inbound-files', '请查看附件', {
|
|
files: inboundSources,
|
|
}), '已读取附件。');
|
|
|
|
assert.equal(ingressCalls.length, 1);
|
|
assert.equal(ingressCalls[0].sessionId, 'session-inbound-files');
|
|
assert.equal(ingressCalls[0].workspace, '/tmp/exact-session-cwd');
|
|
assert.deepEqual(ingressCalls[0].files, inboundSources);
|
|
assert.equal(ingressCalls[0].files[0], inboundSources[0]);
|
|
assert.equal(ingressCalls[0].signal, undefined);
|
|
assert.equal(promptPayload.sessionId, 'session-inbound-files');
|
|
assert.equal(promptPayload.mode, 'queue');
|
|
assert.equal(promptPayload.content.length, 1);
|
|
assert.equal(promptPayload.content[0].type, 'text');
|
|
const promptText = promptPayload.content[0].text;
|
|
assert.match(promptText, /^请查看附件\n\n<dsh_im_files>\n/);
|
|
assert.match(promptText, /\n<\/dsh_im_files>$/);
|
|
const manifest = JSON.parse(promptText.match(/<dsh_im_files>\n(.+)\n<\/dsh_im_files>$/s)[1]);
|
|
assert.deepEqual(manifest, {
|
|
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
|
|
files: stagedFiles,
|
|
});
|
|
assert.doesNotMatch(promptText, /summari[sz]e|解析|总结|处理这些文件/i);
|
|
assert.equal(cleanupCalls, 1);
|
|
});
|
|
|
|
test('HarnessClient cleans staged inbound files when session.prompt rejects them', async () => {
|
|
const promptFailure = new Error('Harness rejected prompt');
|
|
let cleanupCalls = 0;
|
|
let prompted = false;
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/default-workspace',
|
|
fileIngressExecutor: async () => ({
|
|
files: [{ name: 'rejected.dat', path: '.dsh-im/inbound/turn-rejected/01-rejected.dat' }],
|
|
async cleanup() { cleanupCalls += 1; },
|
|
}),
|
|
});
|
|
client.ensureRunning = async () => undefined;
|
|
client.rpc = async (method) => {
|
|
if (method === 'session.history' && !prompted) return { events: [] };
|
|
if (method === 'session.list') {
|
|
return { items: [{ sessionId: 'session-rejected-file', cwd: '/tmp/rejected-cwd' }] };
|
|
}
|
|
assert.equal(method, 'session.prompt');
|
|
prompted = true;
|
|
throw promptFailure;
|
|
};
|
|
|
|
await assert.rejects(
|
|
client.ask('session-rejected-file', '', {
|
|
files: [{ name: 'rejected.dat', data: Buffer.from('bytes') }],
|
|
}),
|
|
(error) => error === promptFailure,
|
|
);
|
|
assert.equal(cleanupCalls, 1);
|
|
});
|
|
|
|
test('HarnessClient retains staged files when an accepted turn outcome is unknown', async () => {
|
|
const uncertainFailure = new Error('history transport failed after prompt acceptance');
|
|
let cleanupCalls = 0;
|
|
let prompted = false;
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace: '/tmp/default-workspace',
|
|
fileIngressExecutor: async () => ({
|
|
files: [{ name: 'uncertain.dat', path: '.dsh-im/inbound/turn-unknown/01-uncertain.dat' }],
|
|
async cleanup() { cleanupCalls += 1; },
|
|
}),
|
|
});
|
|
client.ensureRunning = async () => undefined;
|
|
client.rpc = async (method) => {
|
|
if (method === 'session.history' && !prompted) return { events: [] };
|
|
if (method === 'session.list') {
|
|
return { items: [{ sessionId: 'session-uncertain-file', cwd: '/tmp/uncertain-cwd' }] };
|
|
}
|
|
if (method === 'session.prompt') {
|
|
prompted = true;
|
|
return { accepted: true };
|
|
}
|
|
assert.equal(method, 'session.history');
|
|
throw uncertainFailure;
|
|
};
|
|
|
|
await assert.rejects(
|
|
client.ask('session-uncertain-file', 'use the attached file', {
|
|
files: [{ name: 'uncertain.dat', data: Buffer.from('bytes') }],
|
|
timeoutMs: 1_000,
|
|
}),
|
|
(error) => error === uncertainFailure,
|
|
);
|
|
assert.equal(cleanupCalls, 0, 'uncertain accepted turns may still be reading the staged path');
|
|
});
|
|
|
|
test('HarnessClient delivers an existing file-only Turn directly', async (t) => {
|
|
outboundArtifactRegistry.clear();
|
|
t.after(() => outboundArtifactRegistry.clear());
|
|
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-file-only-'));
|
|
t.after(() => rm(workspace, { recursive: true, force: true }));
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3080',
|
|
workspace,
|
|
});
|
|
client.ensureRunning = async () => undefined;
|
|
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
|
const delivered = [];
|
|
await writeFile(join(workspace, 'file-only.txt'), 'file only');
|
|
let promptRpcId;
|
|
let prompted = false;
|
|
const agent = {
|
|
session: {
|
|
header: { id: 'session-file-only', cwd: workspace },
|
|
events: [],
|
|
},
|
|
};
|
|
client.rpc = async (method, _payload, _timeoutMs, options) => {
|
|
if (method === 'session.history' && !prompted) return { events: [] };
|
|
if (method === 'session.prompt') {
|
|
prompted = true;
|
|
promptRpcId = options.rpcId;
|
|
agent.session.events = [
|
|
{ type: 'turn/start', data: { turn: 2 } },
|
|
{ type: 'user/message', data: { turn: 2, source: { rpcId: promptRpcId } } },
|
|
];
|
|
const exec = {
|
|
name: OUTBOUND_ARTIFACT_TOOL,
|
|
callId: 'file-only-call',
|
|
token: Symbol('file-only-call'),
|
|
agent,
|
|
signal: new AbortController().signal,
|
|
};
|
|
await tool.definition.execute({ path: 'file-only.txt' }, exec);
|
|
tool.onResult(exec, { isError: false });
|
|
return {};
|
|
}
|
|
return {
|
|
events: [
|
|
{ event: { type: 'turn/start', seq: 1, data: { turn: 2 } } },
|
|
{
|
|
event: {
|
|
type: 'user/message',
|
|
seq: 2,
|
|
data: { turn: 2, source: { rpcId: promptRpcId } },
|
|
},
|
|
},
|
|
{ event: { type: 'turn/end', seq: 3, data: { turn: 2, reason: { kind: 'completed' } } } },
|
|
],
|
|
};
|
|
};
|
|
|
|
const answer = await client.ask('session-file-only', 'create and return a file', {
|
|
onArtifact: async (artifact) => delivered.push(artifact),
|
|
});
|
|
assert.equal(answer, '');
|
|
assert.equal(delivered.length, 1);
|
|
const file = await materializeOutboundArtifact(delivered[0]);
|
|
assert.equal(file.bytes.toString(), 'file only');
|
|
releaseOutboundArtifact(delivered[0]);
|
|
});
|
|
|
|
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
|
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
|
|
|
|
assert.equal(tracker.consume([
|
|
{ event: { type: 'turn/start', seq: 11, data: { turn: 4 } } },
|
|
{ event: {
|
|
type: 'user/message',
|
|
seq: 12,
|
|
data: { source: { rpcId: 'someone-else' } },
|
|
} },
|
|
{ event: {
|
|
type: 'assistant/chunk',
|
|
seq: 13,
|
|
data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: '忽略' } },
|
|
} },
|
|
{ event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } },
|
|
]), null);
|
|
assert.equal(tracker.finished, false);
|
|
|
|
const first = tracker.consume([
|
|
{ event: { type: 'turn/start', seq: 15, data: { turn: 5 } } },
|
|
{ event: {
|
|
type: 'user/message',
|
|
seq: 16,
|
|
data: { source: { rpcId: 'prompt-1' } },
|
|
} },
|
|
{ event: {
|
|
type: 'assistant/chunk',
|
|
seq: 17,
|
|
data: { turn: 5, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: '不能泄露' } },
|
|
} },
|
|
{ event: {
|
|
type: 'assistant/chunk',
|
|
seq: 18,
|
|
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '深圳' } },
|
|
} },
|
|
]);
|
|
assert.deepEqual(first, { type: 'text', text: '深圳' });
|
|
|
|
const second = tracker.consume([
|
|
{ event: {
|
|
type: 'assistant/chunk',
|
|
seq: 18,
|
|
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '重复' } },
|
|
} },
|
|
{ event: {
|
|
type: 'assistant/chunk',
|
|
seq: 19,
|
|
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '明天有雨' } },
|
|
} },
|
|
]);
|
|
assert.deepEqual(second, { type: 'text', text: '深圳明天有雨' });
|
|
|
|
const final = tracker.consume([
|
|
{ event: {
|
|
type: 'assistant/message',
|
|
seq: 20,
|
|
data: {
|
|
turn: 5,
|
|
step: 1,
|
|
message: { content: [
|
|
{ type: 'reasoning', text: '仍然不能泄露' },
|
|
{ type: 'text', text: '深圳明天有阵雨。' },
|
|
] },
|
|
},
|
|
} },
|
|
{ event: { type: 'turn/end', seq: 21, data: { turn: 5, reason: { kind: 'completed' } } } },
|
|
]);
|
|
assert.deepEqual(final, { type: 'text', text: '深圳明天有阵雨。' });
|
|
assert.equal(tracker.finished, true);
|
|
assert.equal(tracker.answer, '深圳明天有阵雨。');
|
|
assert.deepEqual(tracker.reason, { kind: 'completed' });
|
|
});
|
|
|
|
test('HarnessReplyTracker emits tool progress without exposing tool results', () => {
|
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-tool' });
|
|
const update = tracker.consume([
|
|
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
|
|
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-tool' } } },
|
|
{ type: 'tool/call', seq: 3, data: { turn: 1, step: 1, name: 'web_search' } },
|
|
]);
|
|
assert.deepEqual(update, { type: 'tool', name: 'web_search' });
|
|
|
|
assert.deepEqual(tracker.consume([
|
|
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
|
|
]), { type: 'status', text: '正在整理结果…', toolName: 'web_search' });
|
|
});
|
|
|
|
test('HarnessReplyTracker keeps every frame of a batched turn in order', () => {
|
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-batch' });
|
|
const updates = tracker.consumeAll([
|
|
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
|
|
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-batch' } } },
|
|
{ type: 'assistant/chunk', seq: 3, data: { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: '先创建再观察:' } } },
|
|
{ type: 'tool/call', seq: 4, data: { turn: 1, step: 1, name: 'add_observations' } },
|
|
{ type: 'tool/result', seq: 5, data: { turn: 1, step: 1, error: { message: 'Status code: 404.' } } },
|
|
{ type: 'tool/call', seq: 6, data: { turn: 1, step: 2, name: 'create_entities' } },
|
|
]);
|
|
assert.deepEqual(updates, [
|
|
{ type: 'text', text: '先创建再观察:' },
|
|
{ type: 'tool', name: 'add_observations' },
|
|
{ type: 'status', text: '正在整理结果…', toolName: 'add_observations', error: 'Status code: 404.' },
|
|
{ type: 'tool', name: 'create_entities' },
|
|
]);
|
|
assert.equal(tracker.answer, '先创建再观察:');
|
|
});
|