feat: support inbound files across all channels

This commit is contained in:
xmanrui 2026-08-23 22:39:26 +08:00
parent 29bcb67a64
commit 0a26d7df92
45 changed files with 2651 additions and 343 deletions

View file

@ -644,6 +644,162 @@ test('HarnessClient asks do not control file-return tool availability', async ()
}), '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());