mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 02:53:20 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
|
|
@ -5,6 +5,7 @@ import {
|
|||
createDingtalkApi,
|
||||
DINGTALK_AI_CARD_TEMPLATE_ID,
|
||||
DINGTALK_API_BASE_URL,
|
||||
DingtalkApiError,
|
||||
normalizeDingtalkCardMarkdown,
|
||||
normalizeDingtalkSessionWebhook,
|
||||
splitDingtalkText,
|
||||
|
|
@ -113,6 +114,231 @@ test('session replies use the fixed token endpoint, reject redirects, and cache
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk uploads and sends a native file message to the exact robot conversation', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.pathname.endsWith('/media/upload')) {
|
||||
return jsonResponse({ errcode: 0, media_id: '@media-one', type: 'file' });
|
||||
}
|
||||
return jsonResponse({ processQueryKey: 'query-one' });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const response = await api.sendFile({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'group',
|
||||
robotCode: 'robot-code',
|
||||
openConversationId: 'cid-one',
|
||||
},
|
||||
file: {
|
||||
fileName: 'result.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
bytes: Buffer.from('dingtalk-result'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.processQueryKey, 'query-one');
|
||||
const uploadUrl = new URL(calls[1].url);
|
||||
assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com');
|
||||
assert.equal(uploadUrl.pathname, '/media/upload');
|
||||
assert.equal(uploadUrl.searchParams.get('access_token'), 'file-access-token');
|
||||
assert.equal(uploadUrl.searchParams.get('type'), 'file');
|
||||
assert.ok(calls[1].options.body instanceof FormData);
|
||||
const media = calls[1].options.body.get('media');
|
||||
assert.equal(media.name, 'result.pdf');
|
||||
assert.equal(media.type, 'application/pdf');
|
||||
assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-result');
|
||||
assert.equal(calls[1].options.headers, undefined);
|
||||
|
||||
assert.equal(
|
||||
calls[2].url,
|
||||
`${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`,
|
||||
);
|
||||
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'file-access-token');
|
||||
const sent = JSON.parse(calls[2].options.body);
|
||||
assert.deepEqual(sent, {
|
||||
robotCode: 'robot-code',
|
||||
msgKey: 'sampleFile',
|
||||
msgParam: JSON.stringify({
|
||||
mediaId: '@media-one',
|
||||
fileName: 'result.pdf',
|
||||
fileType: 'pdf',
|
||||
}),
|
||||
openConversationId: 'cid-one',
|
||||
});
|
||||
});
|
||||
|
||||
function dingtalkFileRequest(overrides = {}) {
|
||||
return {
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'group',
|
||||
robotCode: 'robot-code',
|
||||
openConversationId: 'cid-error-case',
|
||||
},
|
||||
file: {
|
||||
fileName: 'result.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
bytes: Buffer.from('dingtalk-error-case'),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function dingtalkFileFetch(finalResponse) {
|
||||
return async (url, options) => {
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.pathname.endsWith('/media/upload')) {
|
||||
return jsonResponse({ errcode: 0, media_id: '@media-error-case', type: 'file' });
|
||||
}
|
||||
return finalResponse(url, options);
|
||||
};
|
||||
}
|
||||
|
||||
test('DingTalk marks every ambiguous robot file send result as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'network failure',
|
||||
finalResponse: async () => { throw new TypeError('private socket detail'); },
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
finalResponse: async () => {
|
||||
throw new DingtalkApiError('timeout', 'private timeout detail');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
finalResponse: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('private invalid JSON detail'); },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'HTTP 5xx',
|
||||
finalResponse: async () => jsonResponse({ code: 'InternalError' }, { status: 503 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(scenario.finalResponse),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk maps definitive robot file rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
{ name: 'too large', status: 413, code: 'artifact-too-large' },
|
||||
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
|
||||
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => jsonResponse(
|
||||
{ code: scenario.status },
|
||||
{ status: scenario.status },
|
||||
)),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === scenario.code,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const permissionApi = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => jsonResponse({
|
||||
code: 'Forbidden.AccessDenied',
|
||||
})),
|
||||
});
|
||||
await assert.rejects(
|
||||
permissionApi.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 'Forbidden.AccessDenied',
|
||||
);
|
||||
|
||||
const rejectedApi = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => jsonResponse({ code: 'InvalidParameter' })),
|
||||
});
|
||||
await assert.rejects(
|
||||
rejectedApi.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === 'InvalidParameter',
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk preserves caller abort and never marks a pre-send upload failure uncertain', async () => {
|
||||
let uploadCalls = 0;
|
||||
const uploadApi = createDingtalkApi({
|
||||
fetchImpl: async (url) => {
|
||||
uploadCalls += 1;
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
throw new TypeError('private upload transport failure');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
uploadApi.sendFile(dingtalkFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-failed'
|
||||
&& error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
assert.equal(uploadCalls, 2);
|
||||
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('caller stopped the turn');
|
||||
const abortApi = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => {
|
||||
controller.abort(reason);
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
abortApi.sendFile(dingtalkFileRequest({ signal: controller.signal })),
|
||||
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk lets the provider decide whether a robot file type is supported', async () => {
|
||||
let providerCalls = 0;
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => {
|
||||
providerCalls += 1;
|
||||
return jsonResponse({ code: 400 }, { status: 400 });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: { type: 'user', robotCode: 'robot-code', userId: 'user-one' },
|
||||
file: { fileName: 'result.html', bytes: Buffer.from('<h1>result</h1>') },
|
||||
}),
|
||||
(error) => error.code === 'artifact-provider-rejected',
|
||||
);
|
||||
assert.equal(providerCalls, 1);
|
||||
});
|
||||
|
||||
test('DingTalk image downloads exchange downloadCode with the callback robotCode and do not forward auth', async () => {
|
||||
const imageBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
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 test from 'node:test';
|
||||
|
||||
import {
|
||||
|
|
@ -6,6 +9,12 @@ import {
|
|||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -82,6 +91,37 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-dingtalk-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'dingtalk-artifact-one' });
|
||||
t.after(() => registry.clear());
|
||||
const fileContent = fileName.endsWith('.pdf') ? `%PDF-1.7\n${content}` : content;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'artifact-session', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'artifact-rpc' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), fileContent);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'dingtalk-artifact-call',
|
||||
rootCallId: 'dingtalk-artifact-call',
|
||||
token: Symbol('dingtalk-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('artifact-session', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
|
||||
const privateFixture = stateFixture();
|
||||
const privateSent = [];
|
||||
|
|
@ -114,6 +154,161 @@ test('DingTalk remembers any private inbound session webhook for connection test
|
|||
assert.equal(connectionTestTarget(groupFixture.state), null);
|
||||
});
|
||||
|
||||
test('DingTalk returns a registered result file through the native robot conversation', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.pdf', 'dingtalk-result');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-artifact');
|
||||
const order = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async ({ text }) => {
|
||||
order.push(`text:${text}`);
|
||||
return { messageId: 'dingtalk-text-one' };
|
||||
},
|
||||
sendFile: async (request) => {
|
||||
order.push(`file:${request.file.fileName}`);
|
||||
assert.deepEqual(request.target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
assert.equal(request.file.bytes.toString(), '%PDF-1.7\ndingtalk-result');
|
||||
return { processQueryKey: 'dingtalk-file-query-one' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'dingtalk-artifact',
|
||||
'生成文件',
|
||||
{ robotCode: 'robot-code' },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.pdf']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'dingtalk-artifact',
|
||||
presentation: 'dingtalk-text-and-files',
|
||||
providerMessageIds: ['dingtalk-text-one', 'dingtalk-file-query-one'],
|
||||
artifacts: [{ artifactId: 'dingtalk-artifact-one', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('DingTalk still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'dingtalk-text-failed.pdf', 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-artifact-text-failed');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('private text failure');
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
files.push(file.fileName);
|
||||
return { messageId: 'dingtalk-file-after-text-failure' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('dingtalk-artifact-text-failed', '生成文件', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.deepEqual(files, ['dingtalk-text-failed.pdf']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['dingtalk-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
outcome: 'sent',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('DingTalk gives safe, actionable file delivery failure guidance', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'uncertain delivery',
|
||||
code: 'artifact-delivery-uncertain',
|
||||
fileName: 'dingtalk-uncertain.pdf',
|
||||
expected: '结果文件「dingtalk-uncertain.pdf」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
},
|
||||
{
|
||||
name: 'missing permission',
|
||||
code: 'artifact-permission-required',
|
||||
fileName: 'dingtalk-permission.pdf',
|
||||
expected: '结果文件「dingtalk-permission.pdf」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。',
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', `session-${scenario.name}`);
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `dingtalk-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private provider rejection detail');
|
||||
error.code = scenario.code;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(`dingtalk-artifact-${scenario.name}`, '生成文件', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1), scenario.expected);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider rejection detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
outcome: scenario.code === 'artifact-delivery-uncertain' ? 'unknown' : 'rejected',
|
||||
reason: scenario.code,
|
||||
}]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
|
|
|
|||
|
|
@ -66,7 +66,14 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
});
|
||||
assert.ok(runtime instanceof Runtime);
|
||||
assert.equal(seen.runtimeOptions.clientSecret, 'host-only-secret');
|
||||
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
|
||||
assert.match(seen.statePath, /dt_abc\/state\.json$/);
|
||||
await seen.controllerOptions.createRuntime({
|
||||
botId: 'dt_disabled',
|
||||
config: { botId: 'dt_disabled', clientId: 'dingdisabled' },
|
||||
clientSecret: 'host-only-secret',
|
||||
});
|
||||
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
|
||||
|
||||
await production.close();
|
||||
assert.equal(seen.supervisorClosed, true);
|
||||
|
|
@ -93,6 +100,7 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
config: { botId: 'dt_new', clientId: 'dingnew' },
|
||||
clientSecret: 'host-only-secret',
|
||||
});
|
||||
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
|
||||
const stored = JSON.parse(await readFile(join(directory, 'workspaces.json'), 'utf8'));
|
||||
assert.equal(stored.agentPresets.dt_new, 'router-standard');
|
||||
assert.equal(Object.hasOwn(stored.agentPresets, 'dt_abc'), false);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,162 @@ test('Discord API retries one rate-limited message request', async () => {
|
|||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Discord API uploads a result file as a native attachment and preserves the reply', async () => {
|
||||
let request;
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url, options };
|
||||
return jsonResponse({ id: '987654321012345679', attachments: [{ id: '1' }] });
|
||||
},
|
||||
});
|
||||
const result = await api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
replyToMessageId: '123456789012345679',
|
||||
file: {
|
||||
artifactId: 'artifact-discord-one',
|
||||
deliveryKey: 'session:turn:artifact-discord-one',
|
||||
fileName: 'result.html',
|
||||
mediaType: 'text/html',
|
||||
bytes: Buffer.from('<p>discord-result</p>'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.id, '987654321012345679');
|
||||
assert.match(request.url.pathname, /channels\/123456789012345678\/messages$/);
|
||||
assert.equal(request.options.headers['content-type'], undefined);
|
||||
assert.ok(request.options.body instanceof FormData);
|
||||
const payload = JSON.parse(request.options.body.get('payload_json'));
|
||||
assert.deepEqual(payload.attachments, [{ id: 0, filename: 'result.html' }]);
|
||||
assert.match(payload.nonce, /^[0-9a-f]{25}$/);
|
||||
assert.equal(payload.enforce_nonce, true);
|
||||
assert.equal(payload.message_reference.message_id, '123456789012345679');
|
||||
assert.deepEqual(payload.allowed_mentions, { parse: [], replied_user: false });
|
||||
const attachment = request.options.body.get('files[0]');
|
||||
assert.equal(attachment.name, 'result.html');
|
||||
assert.equal(attachment.type, 'text/html');
|
||||
assert.equal(Buffer.from(await attachment.arrayBuffer()).toString(), '<p>discord-result</p>');
|
||||
});
|
||||
|
||||
test('Discord attachment retry reuses one FormData body and one stable nonce', async () => {
|
||||
const bodies = [];
|
||||
const nonces = [];
|
||||
let attempts = 0;
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (_url, options) => {
|
||||
attempts += 1;
|
||||
bodies.push(options.body);
|
||||
nonces.push(JSON.parse(options.body.get('payload_json')).nonce);
|
||||
return attempts === 1
|
||||
? jsonResponse({ code: 20028, message: 'rate limited', retry_after: 0.001 }, 429)
|
||||
: jsonResponse({ id: '987654321012345680', attachments: [{ id: '1' }] });
|
||||
},
|
||||
});
|
||||
const result = await api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: {
|
||||
deliveryKey: 'session:turn:artifact-retry',
|
||||
fileName: 'result.txt',
|
||||
bytes: Buffer.from('retry-safe'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.id, '987654321012345680');
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
assert.equal(nonces[0], nonces[1]);
|
||||
assert.match(nonces[0], /^[0-9a-f]{25}$/);
|
||||
});
|
||||
|
||||
test('Discord attachment errors retain provider details and use stable artifact reasons', async () => {
|
||||
const cases = [{
|
||||
body: { code: 50013, message: 'Missing Permissions' },
|
||||
status: 403,
|
||||
code: 'artifact-permission-required',
|
||||
}, {
|
||||
body: { code: 40005, message: 'Request entity too large' },
|
||||
status: 413,
|
||||
code: 'artifact-too-large',
|
||||
}, {
|
||||
body: { code: 20028, message: 'rate limited', retry_after: 0.001 },
|
||||
status: 429,
|
||||
code: 'artifact-rate-limited',
|
||||
retryAfter: 0.001,
|
||||
}, {
|
||||
body: { code: 50035, message: 'Invalid Form Body' },
|
||||
status: 400,
|
||||
code: 'artifact-provider-rejected',
|
||||
}, {
|
||||
body: { code: 0, message: 'Internal Server Error' },
|
||||
status: 500,
|
||||
code: 'artifact-delivery-uncertain',
|
||||
}];
|
||||
|
||||
for (const entry of cases) {
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse(entry.body, entry.status),
|
||||
});
|
||||
await assert.rejects(() => api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => {
|
||||
assert.equal(error.code, entry.code);
|
||||
assert.equal(error.providerCode, entry.body.code);
|
||||
assert.equal(error.status, entry.status);
|
||||
assert.equal(error.retry_after, entry.retryAfter);
|
||||
assert.equal(error.retryAfter, entry.retryAfter);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Discord attachment delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => {
|
||||
for (const fetchImpl of [
|
||||
async () => { throw new TypeError('socket reset'); },
|
||||
async () => new Response('not-json', { status: 200 }),
|
||||
]) {
|
||||
const api = new DiscordApi({ token: TOKEN, fetchImpl });
|
||||
await assert.rejects(() => api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
}
|
||||
|
||||
const timeoutApi = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => timeoutApi.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
const caller = new AbortController();
|
||||
const reason = new DOMException('caller stopped', 'AbortError');
|
||||
caller.abort(reason);
|
||||
let calls = 0;
|
||||
const cancelledApi = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({ id: '987654321012345680' });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => cancelledApi.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
signal: caller.signal,
|
||||
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('Discord controller persists a credential reference and exposes only masked identity', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-discord-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
|
|
|||
64
test/channels/discord/production.test.mjs
Normal file
64
test/channels/discord/production.test.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/discord/production.mjs';
|
||||
|
||||
test('Discord production has no per-bot result-file Gate', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-discord-production-artifacts-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
const createRuntime = (botId) => controllerOptions.createRuntime({
|
||||
botId,
|
||||
config: { botId },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
const production = await createProductionController(ctx, {
|
||||
dataDir: join(root, 'default'),
|
||||
}, internals);
|
||||
await createRuntime('discord_enabled');
|
||||
await createRuntime('discord_not_listed');
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -2,11 +2,17 @@ import test from 'node:test';
|
|||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -90,6 +96,41 @@ function textClient(sendText) {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix = '') {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix || 'file'}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix || 'file'}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: `rpc-${suffix || 'file'}` } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix || 'file'}`,
|
||||
rootCallId: `call-${suffix || 'file'}`,
|
||||
token: Symbol(`call-${suffix || 'file'}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Feishu remembers any authorized private inbound message as a connection-test target', async () => {
|
||||
const groupFixture = stateFixture();
|
||||
const groupBridge = new FeishuHarnessBridge({
|
||||
|
|
@ -1879,6 +1920,380 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('Feishu finalizes the answer card before delivering registered result files and reports partial failure', async (t) => {
|
||||
const html = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'html');
|
||||
const generic = await committedArtifact(t, 'notes.txt', 'notes', 'notes');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-artifacts']]);
|
||||
const order = [];
|
||||
const delivered = [];
|
||||
const notices = [];
|
||||
const status = bridgeStatus();
|
||||
const abort = new AbortController();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
addReaction: async () => 'reaction',
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
order.push('card-finalized');
|
||||
return { messageId: 'om-card' };
|
||||
},
|
||||
sendFile: async (chatId, file, options) => {
|
||||
order.push(`file:${file.fileName}`);
|
||||
delivered.push({ chatId, file, options });
|
||||
if (file.fileName === 'notes.txt') {
|
||||
const error = new Error('provider detail must stay private');
|
||||
error.code = 'artifact-rate-limited';
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(html);
|
||||
await options.onArtifact(generic);
|
||||
return '两个结果文件已经生成。';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
bridge.accept(event('om_artifacts', '生成 HTML 和说明文件并发给我'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(order, ['card-finalized', 'file:result.html', 'file:notes.txt']);
|
||||
assert.equal(delivered[0].chatId, 'oc_chat');
|
||||
assert.deepEqual(delivered[0].options, {
|
||||
replyTo: 'om_artifacts',
|
||||
signal: abort.signal,
|
||||
});
|
||||
assert.equal(delivered[0].file.bytes.toString(), '<h1>result</h1>');
|
||||
assert.equal(delivered[1].file.bytes.toString(), 'notes');
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(notices[0], /notes\.txt.*限流/);
|
||||
assert.doesNotMatch(notices[0], /provider detail/);
|
||||
});
|
||||
|
||||
test('Feishu tells users to check the chat before retrying an uncertain file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
|
||||
const notices = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
return { messageId: 'om-uncertain-card' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private transport detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '结果已生成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(notices, [
|
||||
'结果文件「uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'file-only.txt', 'file only', 'file-only');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-file-only']]);
|
||||
const cardContents = [];
|
||||
const files = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => undefined),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async (content) => cardContents.push(content) });
|
||||
return { messageId: 'om-file-only-card' };
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file-only'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_file_only', '只发送结果文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(cardContents, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, ['file-only.txt']);
|
||||
});
|
||||
|
||||
test('a CardKit finalization failure falls back to text and delivers each artifact once without a second prompt', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.txt', 'fallback result', 'fallback');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-fallback-artifact']]);
|
||||
const sent = [];
|
||||
const files = [];
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization failed');
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-fallback-file'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
asks += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '回答已生成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_artifact_card_fallback', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.deepEqual(sent, ['回答已生成']);
|
||||
assert.deepEqual(files, ['fallback.txt']);
|
||||
});
|
||||
|
||||
test('Feishu still delivers a file-only result when CardKit and fallback text both fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
|
||||
const files = [];
|
||||
let fallbackTextAttempts = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => {
|
||||
fallbackTextAttempts += 1;
|
||||
throw new Error('text transport unavailable');
|
||||
}),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization unavailable');
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file-after-text-failure'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
bridge.accept(event('om_file_after_text_failure', '只生成文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(files, ['survives-text-failure.txt']);
|
||||
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
|
||||
});
|
||||
|
||||
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
|
||||
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
|
||||
await t.test(errorCode, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
|
||||
const attemptedTexts = [];
|
||||
const visibleTexts = [];
|
||||
const reactions = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
const text = JSON.parse(request.data.content).text;
|
||||
attemptedTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('text transport unavailable');
|
||||
visibleTexts.push(text);
|
||||
return { code: 0, data: {} };
|
||||
} } } },
|
||||
},
|
||||
channel: {
|
||||
addReaction: async (_messageId, emoji) => {
|
||||
reactions.push(emoji);
|
||||
return `reaction-${emoji}`;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
sendFile: async () => {
|
||||
const error = new Error('unsafe result file');
|
||||
error.code = errorCode;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['p2p:ou_user', `session-${errorCode}`]]).state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(event(`om-${errorCode}`, '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleTexts.length, 1);
|
||||
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(reactions, ['OnIt', 'DONE']);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: artifact.deliveryKey,
|
||||
presentation: 'text-fallback',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: errorCode,
|
||||
}],
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Feishu keeps the generic error when no answer or file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedTexts = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => {
|
||||
attemptedTexts.push(text);
|
||||
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
|
||||
}),
|
||||
channel: {
|
||||
sendFile: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['p2p:ou_user', 'session-no-visible-failure']]).state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.match(attemptedTexts.at(-1), /^处理失败,请稍后重试/);
|
||||
});
|
||||
|
||||
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'cancel-after-card.txt', 'file bytes', 'cancel-after-card');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-cancel-after-card']]);
|
||||
const controller = new AbortController();
|
||||
const fallbackTexts = [];
|
||||
let files = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => fallbackTexts.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
return { messageId: 'om-final-card' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
files += 1;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '卡片已经完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
signal: controller.signal,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
bridge.accept(event('om_cancel_after_card', '生成文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(files, 0);
|
||||
assert.deepEqual(fallbackTexts, []);
|
||||
});
|
||||
|
||||
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
|
||||
const seen = new Set();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ function fakeClient(overrides = {}) {
|
|||
recalls: [],
|
||||
reactionsAdded: [],
|
||||
reactionsRemoved: [],
|
||||
fileUploads: [],
|
||||
};
|
||||
const client = {
|
||||
cardkit: { v1: {
|
||||
|
|
@ -28,6 +29,12 @@ function fakeClient(overrides = {}) {
|
|||
},
|
||||
} },
|
||||
im: { v1: {
|
||||
file: {
|
||||
create: async (request) => {
|
||||
calls.fileUploads.push(request);
|
||||
return { file_key: 'file-key-test' };
|
||||
},
|
||||
},
|
||||
message: {
|
||||
reply: async (request) => {
|
||||
calls.replies.push(request);
|
||||
|
|
@ -54,6 +61,9 @@ function fakeClient(overrides = {}) {
|
|||
|
||||
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||
if (overrides.uploadFile) client.im.v1.file.create = overrides.uploadFile;
|
||||
if (overrides.replyMessage) client.im.v1.message.reply = overrides.replyMessage;
|
||||
if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage;
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
|
|
@ -129,3 +139,393 @@ test('VerifiedFeishuChannel checks reaction API results', async () => {
|
|||
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
|
||||
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel uploads a materialized result and replies with a native file message', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const file = {
|
||||
artifactId: 'artifact-html',
|
||||
deliveryKey: 'delivery-html',
|
||||
fileName: 'result.html',
|
||||
mediaType: 'text/html',
|
||||
size: 19,
|
||||
bytes: Buffer.from('<h1>result</h1>'),
|
||||
};
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', file, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(calls.fileUploads[0].data.file_type, 'stream');
|
||||
assert.equal(calls.fileUploads[0].data.file_name, 'result.html');
|
||||
assert.equal(calls.fileUploads[0].data.file, file.bytes);
|
||||
assert.equal(calls.replies.length, 1);
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'file');
|
||||
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { file_key: 'file-key-test' });
|
||||
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-html',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-stream'],
|
||||
artifacts: [{ artifactId: 'artifact-html', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
createMessage: async (request) => {
|
||||
calls.replies.push(request);
|
||||
return { code: 0, data: { message_id: 'om-created-file' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-generic',
|
||||
deliveryKey: 'delivery-generic',
|
||||
fileName: 'result.bin',
|
||||
bytes: Buffer.from('generic'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.replies[0].params, { receive_id_type: 'chat_id' });
|
||||
assert.equal(calls.replies[0].data.receive_id, 'oc_chat');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'file');
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel retries uncertain message delivery without uploading twice', async () => {
|
||||
const requests = [];
|
||||
let attempts = 0;
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? { code: 230049, msg: 'still sending' }
|
||||
: { code: 0, data: { message_id: 'om-retried-file' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-retry',
|
||||
deliveryKey: 'delivery-retry',
|
||||
fileName: 'retry.txt',
|
||||
bytes: Buffer.from('retry'),
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['om-retried-file']);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel stops after one 230049 retry and never uploads the file twice', async () => {
|
||||
const requests = [];
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
return { code: 230049, msg: 'still sending' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-still-uncertain',
|
||||
deliveryKey: 'delivery-still-uncertain',
|
||||
fileName: 'uncertain.txt',
|
||||
bytes: Buffer.from('uncertain'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.providerCode === 230049);
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel retries an SDK-thrown 230049 with the same file key and UUID', async () => {
|
||||
const requests = [];
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
if (requests.length === 1) {
|
||||
const error = new Error('still sending');
|
||||
error.code = 230049;
|
||||
throw error;
|
||||
}
|
||||
return { code: 0, data: { message_id: 'om-after-thrown-230049' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-thrown-retry',
|
||||
deliveryKey: 'delivery-thrown-retry',
|
||||
fileName: 'thrown-retry.txt',
|
||||
bytes: Buffer.from('retry'),
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['om-after-thrown-230049']);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel bounds upload and message waits independently', async () => {
|
||||
const uploadFixture = fakeClient({ uploadFile: async () => new Promise(() => {}) });
|
||||
const uploadChannel = new VerifiedFeishuChannel({
|
||||
client: uploadFixture.client,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fileMessageTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await assert.rejects(uploadChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-upload-timeout',
|
||||
deliveryKey: 'delivery-upload-timeout',
|
||||
fileName: 'upload-timeout.txt',
|
||||
bytes: Buffer.from('timeout'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed'
|
||||
&& error.cause?.code === 'provider-timeout');
|
||||
assert.equal(uploadFixture.calls.replies.length, 0);
|
||||
|
||||
let messageCalls = 0;
|
||||
const messageFixture = fakeClient({
|
||||
replyMessage: async () => {
|
||||
messageCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const messageChannel = new VerifiedFeishuChannel({
|
||||
client: messageFixture.client,
|
||||
fileUploadTimeoutMs: 100,
|
||||
fileMessageTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await assert.rejects(messageChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-message-timeout',
|
||||
deliveryKey: 'delivery-message-timeout',
|
||||
fileName: 'message-timeout.txt',
|
||||
bytes: Buffer.from('timeout'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.code === 'provider-timeout');
|
||||
assert.equal(messageFixture.calls.fileUploads.length, 1);
|
||||
assert.equal(messageCalls, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel rejects waits longer than the 120 second operation timeout', () => {
|
||||
const { client } = fakeClient();
|
||||
assert.throws(
|
||||
() => new VerifiedFeishuChannel({ client, fileUploadTimeoutMs: 120_001 }),
|
||||
/fileUploadTimeoutMs.*120000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => new VerifiedFeishuChannel({ client, fileMessageTimeoutMs: 120_001 }),
|
||||
/fileMessageTimeoutMs.*120000/,
|
||||
);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel stops after an in-flight upload when file delivery is cancelled', async () => {
|
||||
let uploadStarted;
|
||||
const started = new Promise((resolve) => { uploadStarted = resolve; });
|
||||
const { client, calls } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
uploadStarted();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
|
||||
const sending = channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-upload',
|
||||
deliveryKey: 'delivery-cancelled-upload',
|
||||
fileName: 'cancelled.txt',
|
||||
bytes: Buffer.from('cancelled'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal });
|
||||
|
||||
await started;
|
||||
abort.abort(reason);
|
||||
|
||||
await assert.rejects(sending, (error) => error === reason);
|
||||
assert.equal(calls.replies.length, 0);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel immediately preserves caller abort during an in-flight message send', async () => {
|
||||
let messageStarted;
|
||||
const started = new Promise((resolve) => { messageStarted = resolve; });
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async () => {
|
||||
messageStarted();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
|
||||
const sending = channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-message',
|
||||
deliveryKey: 'delivery-cancelled-message',
|
||||
fileName: 'cancelled-message.txt',
|
||||
bytes: Buffer.from('cancelled'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal });
|
||||
|
||||
await started;
|
||||
abort.abort(reason);
|
||||
|
||||
await assert.rejects(sending, (error) => error === reason
|
||||
&& error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel does not retry an uncertain file message after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
const requests = [];
|
||||
const { client } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(request);
|
||||
abort.abort(reason);
|
||||
return { code: 230049, msg: 'still sending' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-retry',
|
||||
deliveryKey: 'delivery-cancelled-retry',
|
||||
fileName: 'cancelled-retry.txt',
|
||||
bytes: Buffer.from('cancelled retry'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal }), (error) => error === reason);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel lets Feishu decide empty and oversize file outcomes', async () => {
|
||||
const uploads = [];
|
||||
const providerCodes = [234010, 234006, 0];
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async (request) => {
|
||||
uploads.push(request);
|
||||
const code = providerCodes.shift();
|
||||
return code === 0 ? {} : { code };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const base = {
|
||||
artifactId: 'artifact-invalid',
|
||||
deliveryKey: 'delivery-invalid',
|
||||
fileName: 'invalid.bin',
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.alloc(0) }),
|
||||
(error) => error.code === 'artifact-empty' && error.providerCode === 234010,
|
||||
);
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('provider decides') }),
|
||||
(error) => error.code === 'artifact-too-large' && error.providerCode === 234006,
|
||||
);
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('valid bytes') }),
|
||||
(error) => error.code === 'artifact-provider-failed' && !error.message.includes('undefined'),
|
||||
);
|
||||
assert.deepEqual(uploads.map((request) => request.data.file.byteLength), [0, 16, 11]);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel distinguishes upload failure from uncertain final delivery', async () => {
|
||||
const uploadFixture = fakeClient({
|
||||
uploadFile: async () => { throw new Error('upload transport closed'); },
|
||||
});
|
||||
const uploadChannel = new VerifiedFeishuChannel({ client: uploadFixture.client });
|
||||
|
||||
await assert.rejects(uploadChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-upload-transport',
|
||||
deliveryKey: 'delivery-upload-transport',
|
||||
fileName: 'upload.txt',
|
||||
bytes: Buffer.from('upload'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed');
|
||||
assert.equal(uploadFixture.calls.replies.length, 0);
|
||||
|
||||
for (const replyMessage of [
|
||||
async () => { throw new Error('message transport closed'); },
|
||||
async () => ({ code: 0, data: {} }),
|
||||
]) {
|
||||
const messageFixture = fakeClient({ replyMessage });
|
||||
const messageChannel = new VerifiedFeishuChannel({ client: messageFixture.client });
|
||||
await assert.rejects(messageChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-uncertain-message',
|
||||
deliveryKey: 'delivery-uncertain-message',
|
||||
fileName: 'message.txt',
|
||||
bytes: Buffer.from('message'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
assert.equal(messageFixture.calls.fileUploads.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel keeps explicit final provider codes out of the uncertain bucket', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async () => ({ code: 99991672, msg: 'missing scope' }),
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-message-permission',
|
||||
deliveryKey: 'delivery-message-permission',
|
||||
fileName: 'permission.txt',
|
||||
bytes: Buffer.from('permission'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 99991672);
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel classifies missing file permission without exposing provider details', async () => {
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
const error = new Error('raw tenant detail and private diagnostic');
|
||||
error.code = 99991672;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-permission',
|
||||
deliveryKey: 'delivery-permission',
|
||||
fileName: 'result.html',
|
||||
bytes: Buffer.from('result'),
|
||||
}),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.message.includes('im:resource permission')
|
||||
&& !error.message.includes('im:resource:upload')
|
||||
&& !error.message.includes('private diagnostic'),
|
||||
);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', async () => {
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
const transport = new Error('raw transport diagnostic');
|
||||
transport.code = 'ERR_BAD_REQUEST';
|
||||
throw [transport, {
|
||||
code: 99991672,
|
||||
msg: 'raw provider permission URL',
|
||||
}];
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-array-permission',
|
||||
deliveryKey: 'delivery-array-permission',
|
||||
fileName: 'result.txt',
|
||||
bytes: Buffer.from('result'),
|
||||
}),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 99991672
|
||||
&& !error.message.includes('provider permission URL'),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
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;
|
||||
|
|
@ -582,6 +592,124 @@ test('HarnessClient reads the nested workspace.create response used by DSH rc.6'
|
|||
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 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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
const run = fx.registrationRuns.shift();
|
||||
assert.deepEqual(run.options.addons.events.items.tenant, ['im.message.receive_v1']);
|
||||
assert.deepEqual(run.options.addons.callbacks.items, ['card.action.trigger']);
|
||||
assert.ok(run.options.addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(run.options.addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/callbacks', expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: 'cli_callbacks', client_secret: 'callbacks-secret',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
|||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||
fixture.resolveRegistration({
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
const alphaState = constructed.runtime.state;
|
||||
assert.equal(constructed.runtime.botId, 'bot_alpha');
|
||||
assert.equal(constructed.runtime.repair, repair);
|
||||
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_beta',
|
||||
config: {
|
||||
|
|
@ -1235,6 +1236,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
appSecret: 'beta-secret',
|
||||
});
|
||||
const betaState = constructed.runtime.state;
|
||||
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
|
||||
assert.notEqual(alphaState, betaState);
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
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 test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -52,6 +60,37 @@ function message(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-qq-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}` });
|
||||
t.after(() => registry.clear());
|
||||
const rpcId = `rpc-${suffix}`;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
const PNG_BYTES = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
|
|
@ -1419,3 +1458,311 @@ test('QQ propagates the stop signal and cancels its pending question on abort',
|
|||
assert.notEqual(cancellationSignal, controller.signal);
|
||||
assert.equal(cancellationSignal.aborted, false);
|
||||
});
|
||||
|
||||
test('QQ sends registered files after text with the native SDK and continues after one file fails', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first bytes', 'partial-first');
|
||||
const second = await committedArtifact(t, 'second.html', '<h1>second</h1>', 'partial-second');
|
||||
const order = [];
|
||||
const sentTexts = [];
|
||||
const files = [];
|
||||
const status = {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
artifactsSent: 0,
|
||||
artifactSendErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'qq-artifact-partial' };
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (replyTarget, text) => {
|
||||
sentTexts.push({ replyTarget, text });
|
||||
order.push(`text:${text}`);
|
||||
return { id: `text-${sentTexts.length}` };
|
||||
},
|
||||
sendFile: async (replyTarget, source, options) => {
|
||||
files.push({ replyTarget, source, options });
|
||||
order.push(`file:${options.fileName}`);
|
||||
if (options.fileName === 'first.txt') {
|
||||
const error = new Error('private quota detail');
|
||||
error.name = 'UploadDailyLimitExceededError';
|
||||
throw error;
|
||||
}
|
||||
return { message: { id: 'qq-file-2' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件处理完成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-artifacts']]).state,
|
||||
status,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-artifact-partial',
|
||||
replyTarget: target,
|
||||
}));
|
||||
|
||||
assert.deepEqual(order.map((entry) => entry.split(':', 1)[0]), [
|
||||
'text', 'file', 'text', 'file',
|
||||
]);
|
||||
assert.equal(files[0].replyTarget, target);
|
||||
assert.equal(files[0].source.buffer.toString(), 'first bytes');
|
||||
assert.equal(files[0].options.fileName, 'first.txt');
|
||||
assert.equal(typeof files[0].options.onProgress, 'function');
|
||||
assert.equal(files[1].source.buffer.toString(), '<h1>second</h1>');
|
||||
assert.equal(files[1].options.fileName, 'second.html');
|
||||
assert.match(sentTexts[1].text, /first\.txt.*上传额度/);
|
||||
assert.doesNotMatch(sentTexts[1].text, /private quota detail/);
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
});
|
||||
|
||||
test('QQ still delivers registered files when every final text delivery attempt fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('text transport unavailable');
|
||||
},
|
||||
sendFile: async (_target, source, options) => {
|
||||
files.push({ bytes: Buffer.from(source.buffer), fileName: options.fileName });
|
||||
return { message: { id: 'qq-file-after-text-failure' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-text-failure']]).state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-text-failure' }));
|
||||
|
||||
assert.deepEqual(files, [{ bytes: Buffer.from('file bytes'), fileName: 'survives-text-failure.txt' }]);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
});
|
||||
|
||||
test('QQ returns the authoritative receipt and sends one safe notice when text and file delivery fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'mismatch.txt', 'file bytes', 'all-fail');
|
||||
const attemptedTexts = [];
|
||||
const visibleTexts = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
attemptedTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('text transport unavailable');
|
||||
visibleTexts.push(text);
|
||||
return undefined;
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('mismatched file signature');
|
||||
error.code = 'artifact-invalid';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-all-fail']]).state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({ messageId: 'qq-all-fail' }));
|
||||
|
||||
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleTexts.length, 1);
|
||||
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'qq-all-fail',
|
||||
presentation: 'qq-files',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: 'artifact-invalid',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('QQ keeps the generic error when neither the answer nor the file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedTexts = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
attemptedTexts.push(text);
|
||||
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
|
||||
return { id: 'qq-generic-error' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-no-visible-failure']]).state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-no-visible-failure' }));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.equal(attemptedTexts.at(-1), '消息处理失败,请稍后重试。');
|
||||
});
|
||||
|
||||
test('QQ reports an unacknowledged native file send as uncertain instead of inviting a blind retry', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'file bytes', 'uncertain');
|
||||
const texts = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => texts.push(text),
|
||||
sendFile: async () => new Promise(() => {}),
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-uncertain']]).state,
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-uncertain-file' }));
|
||||
|
||||
assert.match(texts[1], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||||
});
|
||||
|
||||
test('QQ runtime cancellation interrupts an in-flight file send and skips later files and notices', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first', 'abort-first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second', 'abort-second');
|
||||
const started = deferred();
|
||||
const controller = new AbortController();
|
||||
const texts = [];
|
||||
const files = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => texts.push(text),
|
||||
sendFile: async (_target, _source, options) => {
|
||||
files.push(options.fileName);
|
||||
started.resolve();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件如下。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-abort-files']]).state,
|
||||
signal: controller.signal,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message({ messageId: 'qq-abort-file' }));
|
||||
await started.promise;
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
await Promise.race([
|
||||
processing,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('QQ abort timed out')), 500)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(files, ['first.txt']);
|
||||
assert.equal(texts.some((text) => text.includes('发送结果未能确认')), false);
|
||||
assert.equal(texts.some((text) => text === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('QQ uses a neutral final text for a file-only Turn', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'only.txt', 'only bytes', 'file-only');
|
||||
const texts = [];
|
||||
const files = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => texts.push(text),
|
||||
sendFile: async (_target, source, options) => {
|
||||
files.push({ bytes: Buffer.from(source.buffer), fileName: options.fileName });
|
||||
return { message: { id: 'qq-file-only' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-file-only']]).state,
|
||||
});
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-file-only' }));
|
||||
|
||||
assert.deepEqual(texts, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, [{ bytes: Buffer.from('only bytes'), fileName: 'only.txt' }]);
|
||||
});
|
||||
|
||||
test('QQ cancellation prevents SDK sendFile', async (t) => {
|
||||
let fileCalls = 0;
|
||||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled bytes', 'cancelled');
|
||||
const controller = new AbortController();
|
||||
const cancelledBridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => {},
|
||||
sendFile: async () => { fileCalls += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
controller.abort(new DOMException('stopped', 'AbortError'));
|
||||
return '停止前的回答';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-cancelled']]).state,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await cancelledBridge.accept(message({ messageId: 'qq-artifact-cancelled' }));
|
||||
assert.equal(fileCalls, 0);
|
||||
});
|
||||
|
|
|
|||
72
test/channels/qq/production.test.mjs
Normal file
72
test/channels/qq/production.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/qq/production.mjs';
|
||||
|
||||
test('QQ production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-qq-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
runtimes.push(options);
|
||||
}
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) {
|
||||
controllerOptions = options;
|
||||
}
|
||||
async close() {}
|
||||
}
|
||||
class QrAuth {}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
QrAuth,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
});
|
||||
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'qq_enabled',
|
||||
config: { botId: 'qq_enabled', appId: 'app-enabled' },
|
||||
appSecret: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'qq_disabled',
|
||||
config: { botId: 'qq_disabled', appId: 'app-disabled' },
|
||||
appSecret: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -184,3 +184,45 @@ test('QQ runtime aborts an in-flight Harness interaction when stopped', async ()
|
|||
await runtime.stop();
|
||||
assert.equal(askSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('QQ runtime enables result-file delivery without per-bot configuration', async () => {
|
||||
const bot = new FakeBot();
|
||||
const finished = deferred();
|
||||
let onArtifact;
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
appSecret: 'secret',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
onArtifact = options.onArtifact;
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => finished.resolve(),
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
bot.emit('message', {}, {
|
||||
kind: 'c2c',
|
||||
rawEventType: 'C2C_MESSAGE_CREATE',
|
||||
senderId: 'owner',
|
||||
senderIsBot: false,
|
||||
content: '生成文件',
|
||||
messageId: 'qq-artifact-gate',
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner', msgId: 'qq-artifact-gate' },
|
||||
});
|
||||
await finished.promise;
|
||||
assert.equal(typeof onArtifact, 'function');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
222
test/channels/shared/delivery-message-ids.test.mjs
Normal file
222
test/channels/shared/delivery-message-ids.test.mjs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DiscordBotClient } from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import { createEditableMessageStream } from '../../../src/channels/shared/editable-message-stream.mjs';
|
||||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { SlackBotClient } from '../../../src/channels/slack/slack-runtime.mjs';
|
||||
import { TelegramBotClient } from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
import { WhatsappBotClient } from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
|
||||
function memoryState() {
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
return {
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, sessionId) => { sessions.set(key, sessionId); },
|
||||
clearSession: async (key) => { sessions.delete(key); },
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
markSeen: async (messageId) => { seen.add(messageId); },
|
||||
};
|
||||
}
|
||||
|
||||
function inboundMessage(messageId, replyTarget) {
|
||||
return {
|
||||
messageId,
|
||||
senderId: 'message-id-test-user',
|
||||
senderIsBot: false,
|
||||
kind: 'direct',
|
||||
conversationId: `conversation-${messageId}`,
|
||||
content: '请返回长文本',
|
||||
addressed: true,
|
||||
replyTarget,
|
||||
};
|
||||
}
|
||||
|
||||
async function textReceipt({ key, bot, answer, replyTarget }) {
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key, label: key },
|
||||
bot,
|
||||
state: memoryState(),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
createSession: async () => `session-${key}`,
|
||||
ask: async () => answer,
|
||||
},
|
||||
});
|
||||
return bridge.accept(inboundMessage(`inbound-${key}`, replyTarget));
|
||||
}
|
||||
|
||||
test('editable message streams retain the initial and every remainder message id', async () => {
|
||||
let nextMessageId = 101;
|
||||
const edited = [];
|
||||
const remainders = [];
|
||||
const stream = createEditableMessageStream({
|
||||
initialText: 'working',
|
||||
limit: 5,
|
||||
create: async () => 100,
|
||||
edit: async (messageId, text) => { edited.push({ messageId, text }); },
|
||||
sendRemainder: async (text) => {
|
||||
remainders.push(text);
|
||||
return { message_id: nextMessageId++ };
|
||||
},
|
||||
messageIdForResult: (result) => result.message_id,
|
||||
});
|
||||
|
||||
await stream.start();
|
||||
await stream.finish('ABCDEFGHIJKLM');
|
||||
|
||||
assert.deepEqual(edited, [{ messageId: 100, text: 'ABCDE' }]);
|
||||
assert.deepEqual(remainders, ['FGHIJ', 'KLM']);
|
||||
assert.deepEqual(stream.providerMessageIds, ['100', '101', '102']);
|
||||
const copy = stream.providerMessageIds;
|
||||
copy.push('not-stored');
|
||||
assert.deepEqual(stream.providerMessageIds, ['100', '101', '102']);
|
||||
});
|
||||
|
||||
test('plain Slack, Telegram, Discord, and WhatsApp receipts retain every split message id', async () => {
|
||||
let slackId = 0;
|
||||
const slack = new SlackBotClient({
|
||||
api: {
|
||||
postMessage: async () => ({ ts: `slack-${++slackId}` }),
|
||||
},
|
||||
});
|
||||
const slackReceipt = await textReceipt({
|
||||
key: 'slack',
|
||||
bot: { sendText: slack.sendText.bind(slack) },
|
||||
answer: 'S'.repeat(38_001),
|
||||
replyTarget: { channelId: 'C-message-ids', threadTs: '1.0' },
|
||||
});
|
||||
assert.deepEqual(slackReceipt.providerMessageIds, ['slack-1', 'slack-2']);
|
||||
|
||||
let telegramId = 200;
|
||||
const telegram = new TelegramBotClient({
|
||||
api: {
|
||||
sendMessage: async () => ({ message_id: ++telegramId }),
|
||||
},
|
||||
});
|
||||
const telegramReceipt = await textReceipt({
|
||||
key: 'telegram',
|
||||
bot: { sendText: telegram.sendText.bind(telegram) },
|
||||
answer: 'T'.repeat(4_001),
|
||||
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
|
||||
});
|
||||
assert.deepEqual(telegramReceipt.providerMessageIds, ['201', '202']);
|
||||
|
||||
let discordId = 0;
|
||||
const discord = new DiscordBotClient({
|
||||
api: {
|
||||
createMessage: async () => ({ id: `discord-${++discordId}` }),
|
||||
},
|
||||
});
|
||||
const discordReceipt = await textReceipt({
|
||||
key: 'discord',
|
||||
bot: { sendText: discord.sendText.bind(discord) },
|
||||
answer: 'D'.repeat(1_901),
|
||||
replyTarget: { channelId: 'discord-channel', replyToMessageId: 'discord-inbound' },
|
||||
});
|
||||
assert.deepEqual(discordReceipt.providerMessageIds, ['discord-1', 'discord-2']);
|
||||
|
||||
let whatsappId = 0;
|
||||
const remembered = [];
|
||||
const whatsapp = new WhatsappBotClient({
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async () => ({ key: { id: `whatsapp-${++whatsappId}` } }),
|
||||
}, {
|
||||
remember: (messageId) => remembered.push(messageId),
|
||||
});
|
||||
const whatsappReceipt = await textReceipt({
|
||||
key: 'whatsapp',
|
||||
bot: { sendText: whatsapp.sendText.bind(whatsapp) },
|
||||
answer: 'W'.repeat(4_001),
|
||||
replyTarget: { jid: '16505550123@s.whatsapp.net' },
|
||||
});
|
||||
assert.deepEqual(whatsappReceipt.providerMessageIds, ['whatsapp-1', 'whatsapp-2']);
|
||||
assert.deepEqual(remembered, ['whatsapp-1', 'whatsapp-2']);
|
||||
});
|
||||
|
||||
test('Slack, Telegram, and Discord stream receipts retain the initial and remainder ids', async () => {
|
||||
let slackRemainder = 0;
|
||||
const slack = new SlackBotClient({
|
||||
api: {
|
||||
startStream: async () => ({ ts: 'slack-stream-first' }),
|
||||
appendStream: async () => ({ ok: true }),
|
||||
stopStream: async () => ({ ok: true }),
|
||||
updateMessage: async () => ({ ok: true }),
|
||||
postMessage: async () => ({ ts: `slack-stream-rest-${++slackRemainder}` }),
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const slackReceipt = await textReceipt({
|
||||
key: 'slack-stream',
|
||||
bot: slack,
|
||||
answer: 'S'.repeat(38_001),
|
||||
replyTarget: { channelId: 'C-stream', threadTs: '2.0' },
|
||||
});
|
||||
assert.deepEqual(slackReceipt.providerMessageIds, [
|
||||
'slack-stream-first',
|
||||
'slack-stream-rest-1',
|
||||
]);
|
||||
|
||||
let telegramId = 300;
|
||||
const telegram = new TelegramBotClient({
|
||||
api: {
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async () => ({ message_id: ++telegramId }),
|
||||
editMessageText: async () => true,
|
||||
},
|
||||
});
|
||||
const telegramReceipt = await textReceipt({
|
||||
key: 'telegram-stream',
|
||||
bot: telegram,
|
||||
answer: 'T'.repeat(4_001),
|
||||
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
|
||||
});
|
||||
assert.deepEqual(telegramReceipt.providerMessageIds, ['301', '302']);
|
||||
|
||||
let discordId = 0;
|
||||
const discord = new DiscordBotClient({
|
||||
api: {
|
||||
sendTyping: async () => true,
|
||||
createMessage: async () => ({ id: `discord-stream-${++discordId}` }),
|
||||
editMessage: async () => ({ id: 'ignored-edit-id' }),
|
||||
},
|
||||
});
|
||||
const discordReceipt = await textReceipt({
|
||||
key: 'discord-stream',
|
||||
bot: discord,
|
||||
answer: 'D'.repeat(1_901),
|
||||
replyTarget: { channelId: 'discord-channel', replyToMessageId: 'discord-inbound' },
|
||||
});
|
||||
assert.deepEqual(discordReceipt.providerMessageIds, [
|
||||
'discord-stream-1',
|
||||
'discord-stream-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the text bridge preserves stream ids before any ids returned only by finish', async () => {
|
||||
const stream = {
|
||||
messageId: 'stream-first',
|
||||
providerMessageIds: ['stream-first', 'stream-remainder'],
|
||||
update() {},
|
||||
async finish() {
|
||||
return { providerMessageIds: ['stream-remainder', 'finish-only'] };
|
||||
},
|
||||
cancel() {},
|
||||
};
|
||||
const receipt = await textReceipt({
|
||||
key: 'custom-stream',
|
||||
bot: {
|
||||
sendText: async () => assert.fail('stream finalization must not fall back to plain text'),
|
||||
openStream: async () => stream,
|
||||
},
|
||||
answer: 'done',
|
||||
replyTarget: { channelId: 'custom-stream' },
|
||||
});
|
||||
|
||||
assert.deepEqual(receipt.providerMessageIds, [
|
||||
'stream-first',
|
||||
'stream-remainder',
|
||||
'finish-only',
|
||||
]);
|
||||
});
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
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 test from 'node:test';
|
||||
|
||||
import { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { HarnessClient } from '../../../src/channels/shared/harness-client.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
createOutboundArtifactTool,
|
||||
outboundArtifactRegistry,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from '../../../src/channels/shared/workspace-session.mjs';
|
||||
|
||||
const CATALOG = {
|
||||
|
|
@ -201,6 +209,7 @@ function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {})
|
|||
calls,
|
||||
client,
|
||||
admitted: admitted.promise,
|
||||
promptRpcId: () => promptRpcId,
|
||||
setText(text) { answer = text; },
|
||||
failHistory(error = new Error('history unavailable')) { historyFailure = error; },
|
||||
finish({ text = '', reason = 'cancelled' } = {}) {
|
||||
|
|
@ -259,6 +268,96 @@ test('a stopped turn returns partial text instead of a generic failure', async (
|
|||
assert.equal(await asking, 'partial result');
|
||||
});
|
||||
|
||||
test('an accepted stop preserves partial text but never hands off a registered artifact', async (t) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const turn = controlledTurn({ sessionId: 'session-stopped-artifact' });
|
||||
const control = { owner: {}, key: 'direct:stopped-artifact' };
|
||||
const handedOff = [];
|
||||
const asking = turn.client.ask(turn.id, 'work', {
|
||||
control,
|
||||
timeoutMs: 2_000,
|
||||
onArtifact: async (artifact) => handedOff.push(artifact),
|
||||
});
|
||||
await turn.admitted;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: turn.id, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled result');
|
||||
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'stopped-artifact-call',
|
||||
rootCallId: 'stopped-artifact-call',
|
||||
token: Symbol('stopped-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
|
||||
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
||||
turn.finish({ text: 'partial result', reason: 'stopped' });
|
||||
assert.equal(await asking, 'partial result');
|
||||
assert.deepEqual(handedOff, []);
|
||||
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
|
||||
});
|
||||
|
||||
test('an accepted stop also discards a file-only result instead of handing it off', async (t) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-file-only-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const turn = controlledTurn({ sessionId: 'session-stopped-file-only' });
|
||||
const control = { owner: {}, key: 'direct:stopped-file-only' };
|
||||
const handedOff = [];
|
||||
const asking = turn.client.ask(turn.id, 'work', {
|
||||
control,
|
||||
timeoutMs: 2_000,
|
||||
onArtifact: async (artifact) => handedOff.push(artifact),
|
||||
});
|
||||
await turn.admitted;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: turn.id, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled file-only result');
|
||||
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'stopped-file-only-call',
|
||||
rootCallId: 'stopped-file-only-call',
|
||||
token: Symbol('stopped-file-only-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
|
||||
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
||||
turn.finish({ reason: 'stopped' });
|
||||
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
|
||||
assert.deepEqual(handedOff, []);
|
||||
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
|
||||
});
|
||||
|
||||
test('accepted stop converts later polling failures to turn-stopped and preserves streamed text', async () => {
|
||||
const withoutText = controlledTurn();
|
||||
const firstControl = { owner: {}, key: 'direct:poll-error' };
|
||||
|
|
|
|||
72
test/channels/shared/production.test.mjs
Normal file
72
test/channels/shared/production.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createTokenProductionController } from '../../../plugin-src/host/channels/shared/production.mjs';
|
||||
|
||||
test('token-channel production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-token-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createTokenProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
}, {
|
||||
channel: 'test-token',
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
});
|
||||
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'bot_enabled',
|
||||
config: { botId: 'bot_enabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'bot_disabled',
|
||||
config: { botId: 'bot_disabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
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 test from 'node:test';
|
||||
|
||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
|
|
@ -121,7 +130,13 @@ function approvalInteraction({
|
|||
};
|
||||
}
|
||||
|
||||
function createBridge({ harness, state, bot, signal, logger } = {}) {
|
||||
function createBridge({
|
||||
harness,
|
||||
state,
|
||||
bot,
|
||||
signal,
|
||||
logger,
|
||||
} = {}) {
|
||||
return new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
bot,
|
||||
|
|
@ -132,6 +147,41 @@ function createBridge({ harness, state, bot, signal, logger } = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-text-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const sessionId = `session-artifact-${suffix}`;
|
||||
const rpcId = `rpc-artifact-${suffix}`;
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take(sessionId, 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
@ -329,6 +379,301 @@ test('a stopped shared-channel turn closes an opened stream instead of leaving a
|
|||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('shared text artifact delivery sends text first and each materialized file in order', async (t) => {
|
||||
const first = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'success-html');
|
||||
const second = await committedArtifact(t, 'notes.txt', 'notes', 'success-text');
|
||||
const fixture = stateFixture();
|
||||
const order = [];
|
||||
const files = [];
|
||||
const target = { id: 'artifact-success-target' };
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
order.push(`text:${text}`);
|
||||
return { id: 'text-success' };
|
||||
},
|
||||
sendFile: async (receivedTarget, file) => {
|
||||
order.push(`file:${file.fileName}`);
|
||||
files.push({ target: receivedTarget, file });
|
||||
return { message_id: `file-${files.length}` };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-success',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'artifact-success',
|
||||
'生成结果文件',
|
||||
{ replyTarget: target },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'text:结果文件如下。',
|
||||
'file:result.html',
|
||||
'file:notes.txt',
|
||||
]);
|
||||
assert.equal(files[0].target, target);
|
||||
assert.equal(files[0].file.bytes.toString(), '<h1>result</h1>');
|
||||
assert.equal(files[1].file.bytes.toString(), 'notes');
|
||||
assert.equal(bridge.status.artifactsSent, 2);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'artifact-success',
|
||||
presentation: 'test-text-and-files',
|
||||
providerMessageIds: ['text-success', 'file-1', 'file-2'],
|
||||
artifacts: [
|
||||
{ artifactId: 'success-html-1', outcome: 'sent' },
|
||||
{ artifactId: 'success-text-1', outcome: 'sent' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('a file-only shared text reply shows one neutral completion message before the file', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'only.txt', 'only file', 'file-only');
|
||||
const fixture = stateFixture();
|
||||
const order = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => order.push(`text:${text}`),
|
||||
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-file-only',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return ' ';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('artifact-file-only', '只生成并发送文件'));
|
||||
|
||||
assert.deepEqual(order, ['text:任务已完成。', 'file:only.txt']);
|
||||
});
|
||||
|
||||
test('shared text delivery still attempts registered files when its final text cannot be sent', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'text-failed.txt', 'still delivered', 'text-failed');
|
||||
const fixture = stateFixture();
|
||||
const files = [];
|
||||
const texts = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
texts.push(text);
|
||||
if (texts.length === 1) throw new Error('private text transport failure');
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
files.push(file.fileName);
|
||||
return { key: { id: 'file-after-text-failure' } };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-text-failed',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-text-failed', '生成文件'));
|
||||
|
||||
assert.deepEqual(files, ['text-failed.txt']);
|
||||
assert.deepEqual(texts, ['文字回答']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'text-failed-1', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('shared text artifact provider failures are isolated from text and later files', async (t) => {
|
||||
const providerFailure = await committedArtifact(t, 'provider.txt', 'bad', 'provider-failure');
|
||||
const oversized = await committedArtifact(t, 'oversized.txt', '123456789', 'oversized');
|
||||
const success = await committedArtifact(t, 'success.txt', 'ok', 'partial-success');
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const attempted = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
return { id: `text-partial-${sent.length}` };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
attempted.push(file.fileName);
|
||||
if (file.fileName === 'provider.txt') {
|
||||
throw new Error('private provider diagnostic');
|
||||
}
|
||||
if (file.fileName === 'oversized.txt') {
|
||||
const error = new Error('provider upload limit');
|
||||
error.code = 'artifact-too-large';
|
||||
throw error;
|
||||
}
|
||||
return { id: 'file-partial-success' };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-partial',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(providerFailure);
|
||||
await options.onArtifact(oversized);
|
||||
await options.onArtifact(success);
|
||||
return '三个文件已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-partial', '生成三个文件'));
|
||||
|
||||
assert.deepEqual(attempted, ['provider.txt', 'oversized.txt', 'success.txt']);
|
||||
assert.equal(sent[0], '三个文件已生成。');
|
||||
assert.match(sent[1], /provider\.txt.*暂时未能发送/);
|
||||
assert.doesNotMatch(sent[1], /private provider diagnostic/);
|
||||
assert.match(sent[2], /oversized\.txt.*超过当前渠道大小上限/);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.equal(bridge.status.artifactSendErrors, 2);
|
||||
assert.deepEqual(receipt.providerMessageIds, [
|
||||
'text-partial-1',
|
||||
'text-partial-2',
|
||||
'text-partial-3',
|
||||
'file-partial-success',
|
||||
]);
|
||||
assert.deepEqual(receipt.artifacts, [
|
||||
{
|
||||
artifactId: 'provider-failure-1',
|
||||
outcome: 'failed',
|
||||
reason: 'artifact-provider-failed',
|
||||
},
|
||||
{ artifactId: 'oversized-1', outcome: 'rejected', reason: 'artifact-too-large' },
|
||||
{ artifactId: 'partial-success-1', outcome: 'sent' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('shared text bridge tells users to check the chat before retrying an uncertain file send', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'bytes', 'uncertain');
|
||||
const sent = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = createBridge({
|
||||
state: stateFixture().state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
textAttempts += 1;
|
||||
if (textAttempts === 1) throw new Error('initial text delivery failed');
|
||||
sent.push(text);
|
||||
return { ts: 'unknown-notice' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private timeout detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-uncertain',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-uncertain', '生成文件'));
|
||||
|
||||
assert.equal(textAttempts, 2, 'must not append a contradictory generic retry notice');
|
||||
assert.match(sent[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||||
assert.doesNotMatch(sent[0], /private timeout detail/);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['unknown-notice']);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'uncertain-1',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('shared text streaming finalizes once before delivering result files', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'stream.txt', 'stream file', 'stream');
|
||||
const fixture = stateFixture();
|
||||
const order = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
sendText: async (_target, text) => order.push(`text:${text}`),
|
||||
sendTyping: async () => order.push('typing'),
|
||||
openStream: async () => {
|
||||
order.push('open');
|
||||
return {
|
||||
messageId: 'stream-message',
|
||||
update: async (text) => order.push(`update:${text}`),
|
||||
finish: async (text) => order.push(`finish:${text}`),
|
||||
cancel: () => order.push('cancel'),
|
||||
};
|
||||
},
|
||||
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-stream',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onUpdate({ type: 'text', text: '处理中' });
|
||||
await options.onArtifact(artifact);
|
||||
return '流式回答完成';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('artifact-stream', '流式生成文件'));
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'typing',
|
||||
'open',
|
||||
'update:处理中',
|
||||
'finish:流式回答完成',
|
||||
'file:stream.txt',
|
||||
]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['stream-message']);
|
||||
});
|
||||
|
||||
test('aborting shared text delivery stops before any registered file is sent', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled', 'cancelled');
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
const sent = [];
|
||||
const files = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
signal: controller.signal,
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
sent.push(text);
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
},
|
||||
sendFile: async (_target, file) => files.push(file.fileName),
|
||||
},
|
||||
harness: {
|
||||
createSession: async () => 'session-artifact-cancelled',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '回答完成';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('artifact-cancelled', '生成后取消'));
|
||||
|
||||
assert.deepEqual(sent, ['回答完成']);
|
||||
assert.deepEqual(files, []);
|
||||
});
|
||||
|
||||
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
|
|||
78
test/channels/slack/production.test.mjs
Normal file
78
test/channels/slack/production.test.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/slack/production.mjs';
|
||||
|
||||
test('Slack production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-slack-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const createConnectionSupervisor = () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
});
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'slack_enabled',
|
||||
config: { botId: 'slack_enabled' },
|
||||
botToken: 'host-only-bot-token',
|
||||
appToken: 'host-only-app-token',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'slack_disabled',
|
||||
config: { botId: 'slack_disabled' },
|
||||
botToken: 'host-only-bot-token',
|
||||
appToken: 'host-only-app-token',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'slack_default',
|
||||
config: { botId: 'slack_default' },
|
||||
botToken: 'host-only-bot-token',
|
||||
appToken: 'host-only-app-token',
|
||||
});
|
||||
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
|
||||
await productionWithDefault.close();
|
||||
});
|
||||
|
|
@ -134,6 +134,269 @@ test('Slack API uses native streaming methods and suppresses generated mass ment
|
|||
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
||||
});
|
||||
|
||||
test('Slack API completes the native external-upload flow in the original thread', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
return jsonResponse({ ok: true, files: [{ id: 'F12345678', title: 'result.txt' }] });
|
||||
},
|
||||
});
|
||||
const result = await api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
threadTs: '1700000000.001',
|
||||
file: {
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('slack-result'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.files[0].id, 'F12345678');
|
||||
assert.deepEqual(calls.map(({ url }) => url.pathname), [
|
||||
'/api/files.getUploadURLExternal',
|
||||
'/upload/v1/TICKET',
|
||||
'/api/files.completeUploadExternal',
|
||||
]);
|
||||
assert.equal(
|
||||
calls[0].options.headers['content-type'],
|
||||
'application/x-www-form-urlencoded;charset=utf-8',
|
||||
);
|
||||
assert.equal(calls[0].options.body, 'filename=result.txt&length=12');
|
||||
assert.equal(calls[1].options.headers.authorization, undefined);
|
||||
assert.equal(Buffer.from(calls[1].options.body).toString(), 'slack-result');
|
||||
assert.equal(calls[2].options.headers['content-type'], 'application/json;charset=utf-8');
|
||||
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||
files: [{ id: 'F12345678', title: 'result.txt' }],
|
||||
channel_id: 'C12345678',
|
||||
thread_ts: '1700000000.001',
|
||||
});
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:write\n/);
|
||||
});
|
||||
|
||||
test('Slack file preparation maps missing scope and pre-delivery failures without claiming uncertainty', async () => {
|
||||
const missingScopeApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ ok: false, error: 'missing_scope' }),
|
||||
});
|
||||
await assert.rejects(() => missingScopeApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 'missing_scope');
|
||||
|
||||
const invalidArgumentsApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ ok: false, error: 'invalid_arguments' }),
|
||||
});
|
||||
await assert.rejects(() => invalidArgumentsApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === 'invalid_arguments'
|
||||
&& error.status === 200);
|
||||
|
||||
const sizeRestrictedApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => jsonResponse({
|
||||
ok: false,
|
||||
error: 'file_upload_size_restricted',
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => sizeRestrictedApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-too-large'
|
||||
&& error.providerCode === 'file_upload_size_restricted'
|
||||
&& error.status === 200);
|
||||
|
||||
for (const ticketResponse of [
|
||||
new Response('not-json', { status: 200 }),
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://example.com/untrusted',
|
||||
file_id: 'F12345678',
|
||||
}),
|
||||
]) {
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async () => ticketResponse,
|
||||
});
|
||||
await assert.rejects(() => api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-failed');
|
||||
}
|
||||
|
||||
const rawUploadTimeoutApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal.aborted) reject(options.signal.reason);
|
||||
else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => rawUploadTimeoutApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-failed'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
const expiredTicketApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
return new Response('', { status });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => expiredTicketApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.status === status);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack completion is never retried and all non-permission failures are uncertain', async () => {
|
||||
const completionCases = [{
|
||||
complete: async () => jsonResponse({ ok: false, error: 'ratelimited' }, 429, {
|
||||
'retry-after': '0.001',
|
||||
}),
|
||||
providerCode: 'ratelimited',
|
||||
}, {
|
||||
complete: async () => new Response('not-json', { status: 200 }),
|
||||
}, {
|
||||
complete: async () => { throw new TypeError('socket reset'); },
|
||||
}, {
|
||||
complete: async () => jsonResponse({ ok: true, files: [] }),
|
||||
}, {
|
||||
complete: async () => jsonResponse({
|
||||
ok: true,
|
||||
files: [{ id: 'F87654321', title: 'result.txt' }],
|
||||
}),
|
||||
}];
|
||||
|
||||
for (const entry of completionCases) {
|
||||
let completionCalls = 0;
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
completionCalls += 1;
|
||||
return entry.complete();
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.providerCode === entry.providerCode);
|
||||
assert.equal(completionCalls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack completion timeout is uncertain while a caller abort remains an abort', async () => {
|
||||
const createApi = ({ complete, fileUploadTimeoutMs = 120_000 }) => new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fileUploadTimeoutMs,
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
return complete(options.signal);
|
||||
},
|
||||
});
|
||||
const timeoutApi = createApi({
|
||||
fileUploadTimeoutMs: 10,
|
||||
complete: async (signal) => new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => timeoutApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
const caller = new AbortController();
|
||||
const reason = new DOMException('caller stopped', 'AbortError');
|
||||
const cancelledApi = createApi({
|
||||
complete: async () => {
|
||||
caller.abort(reason);
|
||||
throw reason;
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => cancelledApi.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
signal: caller.signal,
|
||||
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('Slack completion missing_scope remains a permission error', async () => {
|
||||
let completionCalls = 0;
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url) => {
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/TICKET',
|
||||
file_id: 'F12345678',
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
completionCalls += 1;
|
||||
return jsonResponse({ ok: false, error: 'missing_scope' });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => api.uploadFile({
|
||||
channelId: 'C12345678',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 'missing_scope');
|
||||
assert.equal(completionCalls, 1);
|
||||
});
|
||||
|
||||
test('Slack downloads private files with the bot token from Slack hosts only', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeTelegramAllowedUsers } from '../../../plugin-src/host/channels/telegram/production.mjs';
|
||||
import {
|
||||
createProductionController,
|
||||
normalizeTelegramAllowedUsers,
|
||||
} from '../../../plugin-src/host/channels/telegram/production.mjs';
|
||||
|
||||
test('Telegram per-bot policy normalizes and validates private-message allowlists', () => {
|
||||
assert.deepEqual(normalizeTelegramAllowedUsers(undefined), []);
|
||||
|
|
@ -18,3 +24,54 @@ test('Telegram per-bot policy normalizes and validates private-message allowlist
|
|||
/invalid Telegram User ID/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Telegram production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-telegram-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'telegram_default',
|
||||
config: { botId: 'telegram_default' },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -200,6 +200,136 @@ test('Telegram API registers the command menu and commands-type menu button', as
|
|||
await assert.rejects(() => api.setChatMenuButton({ menuButton: 'commands' }), /menu button is invalid/);
|
||||
});
|
||||
|
||||
test('Telegram API uploads a result file as a native document in the same topic and reply chain', async () => {
|
||||
let request;
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url, options };
|
||||
return jsonResponse({ ok: true, result: { message_id: 901 } });
|
||||
},
|
||||
});
|
||||
const result = await api.sendDocument({
|
||||
chatId: -100123,
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
file: {
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('telegram-result'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.message_id, 901);
|
||||
assert.match(request.url.pathname, /sendDocument$/);
|
||||
assert.equal(request.options.method, 'POST');
|
||||
assert.equal(request.options.headers, undefined);
|
||||
assert.ok(request.options.body instanceof FormData);
|
||||
assert.equal(request.options.body.get('chat_id'), '-100123');
|
||||
assert.equal(request.options.body.get('message_thread_id'), '55');
|
||||
assert.deepEqual(JSON.parse(request.options.body.get('reply_parameters')), {
|
||||
message_id: 44,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
const document = request.options.body.get('document');
|
||||
assert.equal(document.name, 'result.txt');
|
||||
assert.equal(document.type, 'text/plain');
|
||||
assert.equal(Buffer.from(await document.arrayBuffer()).toString(), 'telegram-result');
|
||||
});
|
||||
|
||||
test('Telegram document errors retain provider details and use stable artifact reasons', async () => {
|
||||
const cases = [{
|
||||
body: { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
|
||||
status: 403,
|
||||
code: 'artifact-permission-required',
|
||||
}, {
|
||||
body: { ok: false, error_code: 400, description: 'Bad Request: file is too big' },
|
||||
status: 400,
|
||||
code: 'artifact-too-large',
|
||||
}, {
|
||||
body: {
|
||||
ok: false,
|
||||
error_code: 429,
|
||||
description: 'Too Many Requests',
|
||||
parameters: { retry_after: 7 },
|
||||
},
|
||||
status: 429,
|
||||
code: 'artifact-rate-limited',
|
||||
retryAfter: 7,
|
||||
}, {
|
||||
body: { ok: false, error_code: 400, description: 'Bad Request: unsupported document' },
|
||||
status: 400,
|
||||
code: 'artifact-provider-rejected',
|
||||
}, {
|
||||
body: { ok: false, error_code: 500, description: 'Internal Server Error' },
|
||||
status: 500,
|
||||
code: 'artifact-delivery-uncertain',
|
||||
}];
|
||||
|
||||
for (const entry of cases) {
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse(entry.body, entry.status),
|
||||
});
|
||||
await assert.rejects(() => api.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => {
|
||||
assert.equal(error.code, entry.code);
|
||||
assert.equal(error.providerCode, entry.body.error_code);
|
||||
assert.equal(error.status, entry.status);
|
||||
assert.equal(error.retry_after, entry.retryAfter);
|
||||
assert.equal(error.retryAfter, entry.retryAfter);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Telegram document delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => {
|
||||
for (const fetchImpl of [
|
||||
async () => { throw new TypeError('socket reset'); },
|
||||
async () => new Response('not-json', { status: 200 }),
|
||||
]) {
|
||||
const api = new TelegramApi({ token: TOKEN, fetchImpl });
|
||||
await assert.rejects(() => api.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
}
|
||||
|
||||
const timeoutApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => timeoutApi.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
|
||||
const caller = new AbortController();
|
||||
const reason = new DOMException('caller stopped', 'AbortError');
|
||||
caller.abort(reason);
|
||||
let calls = 0;
|
||||
const cancelledApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({ ok: true, result: {} });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => cancelledApi.sendDocument({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
|
||||
signal: caller.signal,
|
||||
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('Telegram config and controller store only a credential reference in bot data', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
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 test from 'node:test';
|
||||
|
||||
import {
|
||||
|
|
@ -7,6 +10,11 @@ import {
|
|||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -77,6 +85,37 @@ function testClient() {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-wecom-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}` });
|
||||
t.after(() => registry.clear());
|
||||
const rpcId = `rpc-${suffix}`;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function questionInteraction({
|
||||
interactionId = 'question-1',
|
||||
sessionId = 'session-existing',
|
||||
|
|
@ -1219,3 +1258,368 @@ test('aborting Enterprise WeChat work cancels its pending question without a fai
|
|||
assert.equal(cancellations[0].responseOptions.signal.aborted, false);
|
||||
assert.equal(transport.streamed.some(({ content }) => content === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat sends registered files after the final text and continues after one file fails', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first bytes', 'partial-first');
|
||||
const second = await committedArtifact(t, 'second.html', '<h1>second</h1>', 'partial-second');
|
||||
const order = [];
|
||||
const uploads = [];
|
||||
const active = [];
|
||||
const status = {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
artifactsSent: 0,
|
||||
artifactSendErrors: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const client = {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (finish) order.push(`text:${content}`);
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (chatId, body) => {
|
||||
const content = body.markdown.content;
|
||||
active.push({ chatId, content });
|
||||
order.push(`notice:${content}`);
|
||||
return { body: { msgid: `notice-${active.length}` } };
|
||||
},
|
||||
uploadMedia: async (bytes, options) => {
|
||||
uploads.push({ bytes: Buffer.from(bytes), options });
|
||||
order.push(`upload:${options.filename}`);
|
||||
return { media_id: `media-${options.filename}` };
|
||||
},
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
order.push(`file:${mediaId}`);
|
||||
if (mediaId === 'media-first.txt') {
|
||||
const error = new Error('private provider detail');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
}
|
||||
return { body: { msgid: 'wecom-file-2' }, chatId, type };
|
||||
},
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client,
|
||||
generateStreamId: () => 'artifact-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件处理完成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
status,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-artifact-partial' }));
|
||||
|
||||
assert.deepEqual(order.map((entry) => entry.split(':', 1)[0]), [
|
||||
'text', 'upload', 'file', 'notice', 'upload', 'file',
|
||||
]);
|
||||
assert.deepEqual(uploads.map(({ options }) => options), [
|
||||
{ type: 'file', filename: 'first.txt' },
|
||||
{ type: 'file', filename: 'second.html' },
|
||||
]);
|
||||
assert.equal(uploads[0].bytes.toString(), 'first bytes');
|
||||
assert.equal(uploads[1].bytes.toString(), '<h1>second</h1>');
|
||||
assert.equal(active.length, 1);
|
||||
assert.match(active[0].content, /first\.txt.*暂时未能/);
|
||||
assert.doesNotMatch(active[0].content, /private provider detail/);
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat still delivers registered files when final text delivery fails', async (t) => {
|
||||
const artifact = await committedArtifact(
|
||||
t,
|
||||
'survives-text-failure.txt',
|
||||
'file bytes',
|
||||
'text-failure',
|
||||
);
|
||||
const files = [];
|
||||
let finalTextAttempts = 0;
|
||||
let activeTextAttempts = 0;
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, _content, finish) => {
|
||||
if (finish) {
|
||||
finalTextAttempts += 1;
|
||||
throw new Error('stream finalization unavailable');
|
||||
}
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {
|
||||
activeTextAttempts += 1;
|
||||
throw new Error('active text unavailable');
|
||||
},
|
||||
uploadMedia: async () => ({ media_id: 'media-after-text-failure' }),
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
files.push({ chatId, type, mediaId });
|
||||
return { body: { msgid: 'wecom-file-after-text-failure' } };
|
||||
},
|
||||
},
|
||||
generateStreamId: () => 'text-failure-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-text-failure' }));
|
||||
|
||||
assert.deepEqual(files, [{
|
||||
chatId: 'member-1',
|
||||
type: 'file',
|
||||
mediaId: 'media-after-text-failure',
|
||||
}]);
|
||||
assert.equal(finalTextAttempts, 1, 'must not append a generic retry stream after file success');
|
||||
assert.equal(activeTextAttempts, 1);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat returns the authoritative receipt and one safe notice when text and file delivery fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'mismatch.txt', 'file bytes', 'all-fail');
|
||||
const attemptedActiveTexts = [];
|
||||
const visibleActiveTexts = [];
|
||||
const finalStreamTexts = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (finish) {
|
||||
finalStreamTexts.push(content);
|
||||
throw new Error('stream finalization unavailable');
|
||||
}
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => {
|
||||
const text = body.markdown.content;
|
||||
attemptedActiveTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('active text unavailable');
|
||||
visibleActiveTexts.push(text);
|
||||
return {};
|
||||
},
|
||||
uploadMedia: async () => {
|
||||
const error = new Error('mismatched file signature');
|
||||
error.code = 'artifact-invalid';
|
||||
throw error;
|
||||
},
|
||||
sendMediaMessage: async () => assert.fail('a rejected upload must not be sent'),
|
||||
},
|
||||
generateStreamId: () => 'all-fail-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-all-fail' }));
|
||||
|
||||
assert.deepEqual(finalStreamTexts, ['文字结果']);
|
||||
assert.equal(attemptedActiveTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleActiveTexts.length, 1);
|
||||
assert.match(visibleActiveTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'wecom-all-fail',
|
||||
presentation: 'wecom-files',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: 'artifact-invalid',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('Enterprise WeChat keeps the generic error when no answer or file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedActiveTexts = [];
|
||||
const finalStreamTexts = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (!finish) return;
|
||||
finalStreamTexts.push(content);
|
||||
if (finalStreamTexts.length === 1) throw new Error('stream finalization unavailable');
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => {
|
||||
attemptedActiveTexts.push(body.markdown.content);
|
||||
throw new Error('active text unavailable');
|
||||
},
|
||||
uploadMedia: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
sendMediaMessage: async () => assert.fail('a rejected upload must not be sent'),
|
||||
},
|
||||
generateStreamId: () => 'no-visible-failure-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-no-visible-failure' }));
|
||||
|
||||
assert.equal(attemptedActiveTexts.length, 2);
|
||||
assert.deepEqual(finalStreamTexts, ['文字结果', '消息处理失败,请稍后重试。']);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reports an unacknowledged file message as uncertain', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'file bytes', 'uncertain');
|
||||
const active = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => active.push(body.markdown.content),
|
||||
uploadMedia: async () => ({ media_id: 'media-uncertain' }),
|
||||
sendMediaMessage: async () => new Promise(() => {}),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文件已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-uncertain-file' }));
|
||||
|
||||
assert.match(active[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat cancellation interrupts an in-flight file send and skips later files', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first', 'abort-first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second', 'abort-second');
|
||||
const started = deferred();
|
||||
const controller = new AbortController();
|
||||
const uploads = [];
|
||||
const active = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => active.push(body.markdown.content),
|
||||
uploadMedia: async (_bytes, options) => {
|
||||
uploads.push(options.filename);
|
||||
return { media_id: `media-${options.filename}` };
|
||||
},
|
||||
sendMediaMessage: async () => {
|
||||
started.resolve();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first);
|
||||
await options.onArtifact(second);
|
||||
return '文件如下。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
signal: controller.signal,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const processing = bridge.accept(frame({ msgid: 'wecom-abort-file' }));
|
||||
await started.promise;
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
await Promise.race([
|
||||
processing,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('WeCom abort timed out')), 500)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(uploads, ['first.txt']);
|
||||
assert.equal(active.some((text) => text.includes('发送结果未能确认')), false);
|
||||
assert.equal(active.some((text) => text === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat uses a neutral final text for a file-only Turn', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'only.txt', 'only bytes', 'file-only');
|
||||
const finalTexts = [];
|
||||
const files = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, content, finish) => {
|
||||
if (finish) finalTexts.push(content);
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async () => ({ media_id: 'media-only' }),
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
files.push({ chatId, type, mediaId });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
generateStreamId: () => 'file-only-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({ msgid: 'wecom-file-only' }));
|
||||
|
||||
assert.deepEqual(finalTexts, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, [{ chatId: 'member-1', type: 'file', mediaId: 'media-only' }]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat cancellation prevents SDK upload', async (t) => {
|
||||
let uploads = 0;
|
||||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled bytes', 'cancelled');
|
||||
const controller = new AbortController();
|
||||
const cancelledBridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async () => { uploads += 1; return { media_id: 'must-not-upload' }; },
|
||||
sendMediaMessage: async () => {},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
controller.abort(new DOMException('stopped', 'AbortError'));
|
||||
return '停止前的回答';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
await cancelledBridge.accept(frame({ msgid: 'wecom-artifact-cancelled' }));
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
|
|
|
|||
72
test/channels/wecom/production.test.mjs
Normal file
72
test/channels/wecom/production.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/wecom/production.mjs';
|
||||
|
||||
test('Enterprise WeChat production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-wecom-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
runtimes.push(options);
|
||||
}
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) {
|
||||
controllerOptions = options;
|
||||
}
|
||||
async close() {}
|
||||
}
|
||||
class QrAuth {}
|
||||
const supervisor = {
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
QrAuth,
|
||||
createConnectionSupervisor: () => supervisor,
|
||||
});
|
||||
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wecom_enabled',
|
||||
config: { botId: 'wecom_enabled', remoteBotId: 'remote-enabled' },
|
||||
secret: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wecom_disabled',
|
||||
config: { botId: 'wecom_disabled', remoteBotId: 'remote-disabled' },
|
||||
secret: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
});
|
||||
|
|
@ -157,3 +157,45 @@ test('Enterprise WeChat runtime aborts an in-flight Harness interaction when sto
|
|||
await runtime.stop();
|
||||
assert.equal(askSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat runtime enables result-file delivery without per-bot configuration', async () => {
|
||||
const client = new FakeClient();
|
||||
const finished = deferred();
|
||||
let onArtifact;
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
onArtifact = options.onArtifact;
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => finished.resolve(),
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
createClient: () => client,
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
client.emit('message', {
|
||||
headers: { req_id: 'req-artifact-gate' },
|
||||
body: {
|
||||
msgid: 'msg-artifact-gate',
|
||||
chattype: 'single',
|
||||
from: { userid: 'member-1' },
|
||||
msgtype: 'text',
|
||||
text: { content: '生成文件' },
|
||||
},
|
||||
});
|
||||
await finished.promise;
|
||||
assert.equal(typeof onArtifact, 'function');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
|
|
|||
76
test/channels/weixin/production.test.mjs
Normal file
76
test/channels/weixin/production.test.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/weixin/production.mjs';
|
||||
|
||||
test('Weixin production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-weixin-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const createConnectionSupervisor = () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
});
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
api: {},
|
||||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wx_enabled',
|
||||
config: { botId: 'wx_enabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wx_disabled',
|
||||
config: { botId: 'wx_disabled' },
|
||||
token: 'host-only',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'wx_default',
|
||||
config: { botId: 'wx_default' },
|
||||
token: 'host-only',
|
||||
});
|
||||
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
|
||||
await productionWithDefault.close();
|
||||
});
|
||||
|
|
@ -171,6 +171,243 @@ test('sendText emits the iLink message envelope without reflecting the token in
|
|||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||
});
|
||||
|
||||
test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file item', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=one',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-ticket' },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
const plaintext = Buffer.from('weixin-native-file');
|
||||
const result = await api.sendFile({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
contextToken: 'message-context',
|
||||
runId: 'run-1',
|
||||
file: {
|
||||
artifactId: 'artifact-one',
|
||||
deliveryKey: 'session:turn:artifact-one',
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: plaintext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
const ticket = JSON.parse(calls[0].init.body);
|
||||
assert.equal(ticket.media_type, 3);
|
||||
assert.equal(ticket.to_user_id, 'wx-user');
|
||||
assert.equal(ticket.rawsize, plaintext.length);
|
||||
assert.equal(ticket.rawfilemd5, 'ae8e1f4207c0468828419884f7329cb8');
|
||||
assert.equal(ticket.filesize, 32);
|
||||
assert.equal(ticket.no_need_thumb, true);
|
||||
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
|
||||
|
||||
assert.equal(calls[1].init.headers.Authorization, undefined);
|
||||
assert.equal(calls[1].init.headers['content-type'], 'application/octet-stream');
|
||||
assert.equal(calls[1].init.body.byteLength, 32);
|
||||
assert.equal(
|
||||
decryptWeixinImage(calls[1].init.body, Buffer.from(ticket.aeskey, 'hex')).equals(plaintext),
|
||||
true,
|
||||
);
|
||||
|
||||
const sent = JSON.parse(calls[2].init.body).msg;
|
||||
assert.equal(sent.context_token, 'message-context');
|
||||
assert.equal(sent.run_id, 'run-1');
|
||||
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
|
||||
assert.equal(result.messageId, sent.client_id);
|
||||
assert.deepEqual(sent.item_list, [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'download-ticket',
|
||||
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: 'result.txt',
|
||||
len: String(plaintext.length),
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
function weixinFileRequest(overrides = {}) {
|
||||
return {
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
file: {
|
||||
artifactId: 'artifact-error-case',
|
||||
deliveryKey: 'session:turn:artifact-error-case',
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('weixin-error-case'),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function weixinFileFetch(finalResponse) {
|
||||
return async (url, init) => {
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=error-case',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-error-case' },
|
||||
});
|
||||
}
|
||||
return finalResponse(url, init);
|
||||
};
|
||||
}
|
||||
|
||||
test('sendFile marks every ambiguous sendmessage result as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'network failure',
|
||||
finalResponse: async () => { throw new TypeError('private socket detail'); },
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
finalResponse: async () => {
|
||||
throw new WeixinApiError('timeout', 'private timeout detail');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
finalResponse: async () => new Response('{not-json', { status: 200 }),
|
||||
},
|
||||
{
|
||||
name: 'HTTP 5xx',
|
||||
finalResponse: async () => jsonResponse({ ret: -1 }, { status: 503 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(scenario.finalResponse),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('sendFile maps definitive sendmessage rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
{ name: 'too large', status: 413, code: 'artifact-too-large' },
|
||||
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
|
||||
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse(
|
||||
{ ret: scenario.status },
|
||||
{ status: scenario.status },
|
||||
)),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === scenario.code,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const permissionApi = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: 403 })),
|
||||
});
|
||||
await assert.rejects(
|
||||
permissionApi.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === '403',
|
||||
);
|
||||
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: -2001 })),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === '-2001',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile preserves caller abort and never marks a pre-send preparation failure uncertain', async () => {
|
||||
let preparationCalls = 0;
|
||||
const preparationApi = createWeixinApi({
|
||||
fetchImpl: async () => {
|
||||
preparationCalls += 1;
|
||||
throw new TypeError('private upload preparation failure');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
preparationApi.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-failed'
|
||||
&& error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
assert.equal(preparationCalls, 1);
|
||||
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('caller stopped the turn');
|
||||
const abortApi = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => {
|
||||
controller.abort(reason);
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
abortApi.sendFile(weixinFileRequest({ signal: controller.signal })),
|
||||
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile rejects a provider upload URL outside the canonical Weixin CDN', async () => {
|
||||
let calls = 0;
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://attacker.example/c2c/upload?ticket=one',
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('safe') },
|
||||
}),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'untrusted-upload-url',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('getUpdates converts its own long-poll timeout into an empty successful poll', async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (_url, init) => new Promise((_resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
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 test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -69,6 +78,36 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'weixin-artifact-one' });
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'artifact-session', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'artifact-rpc' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'weixin-artifact-call',
|
||||
rootCallId: 'weixin-artifact-call',
|
||||
token: Symbol('weixin-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('artifact-session', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Weixin remembers any authorized private inbound as a connection-test target', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
@ -98,6 +137,144 @@ test('Weixin remembers any authorized private inbound as a connection-test targe
|
|||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||||
});
|
||||
|
||||
test('Weixin returns a registered result file with native context after its existing text path', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.txt', 'weixin-result');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact');
|
||||
const order = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async ({ text }) => {
|
||||
order.push(`text:${text}`);
|
||||
return { messageId: 'weixin-text-one' };
|
||||
},
|
||||
sendFile: async (request) => {
|
||||
order.push(`file:${request.file.fileName}`);
|
||||
assert.equal(request.toUserId, 'owner-user');
|
||||
assert.equal(request.contextToken, 'context-weixin-artifact');
|
||||
assert.equal(request.runId, 'run-artifact');
|
||||
assert.equal(request.file.bytes.toString(), 'weixin-result');
|
||||
return { messageId: 'weixin-file-one' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'weixin-artifact',
|
||||
'生成文件',
|
||||
{ run_id: 'run-artifact' },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.txt']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'weixin-artifact',
|
||||
presentation: 'weixin-text-and-files',
|
||||
providerMessageIds: ['weixin-text-one', 'weixin-file-one'],
|
||||
artifacts: [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('Weixin still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-text-failed.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact-text-failed');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('private text failure');
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
files.push(file.fileName);
|
||||
return { messageId: 'weixin-file-after-text-failure' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-text-failed', '生成文件'));
|
||||
|
||||
assert.deepEqual(files, ['weixin-text-failed.txt']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['weixin-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('Weixin tells users to inspect the chat instead of retrying an uncertain file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-uncertain.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact-uncertain');
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `weixin-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private provider transport detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-uncertain', '生成文件'));
|
||||
|
||||
assert.equal(
|
||||
sent.at(-1),
|
||||
'结果文件「weixin-uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider transport detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'weixin-artifact-one',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
75
test/channels/whatsapp/production.test.mjs
Normal file
75
test/channels/whatsapp/production.test.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductionController } from '../../../plugin-src/host/channels/whatsapp/production.mjs';
|
||||
|
||||
test('WhatsApp production has no per-bot result-file Gate', async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-whatsapp-production-artifacts-'));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { return this; }
|
||||
}
|
||||
class Harness {
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
async close() {}
|
||||
}
|
||||
const createConnectionSupervisor = () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
});
|
||||
const internals = {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
createSession: async () => ({}),
|
||||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
webServer: { port: 3080 },
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
const production = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_enabled',
|
||||
config: { botId: 'whatsapp_enabled' },
|
||||
authDir: '00000000-0000-4000-8000-000000000001',
|
||||
});
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_disabled',
|
||||
config: { botId: 'whatsapp_disabled' },
|
||||
authDir: '00000000-0000-4000-8000-000000000002',
|
||||
});
|
||||
|
||||
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
|
||||
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
|
||||
await production.close();
|
||||
|
||||
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
|
||||
await controllerOptions.createRuntime({
|
||||
botId: 'whatsapp_default',
|
||||
config: { botId: 'whatsapp_default' },
|
||||
authDir: '00000000-0000-4000-8000-000000000003',
|
||||
});
|
||||
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
|
||||
await productionWithDefault.close();
|
||||
});
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, stat } from 'node:fs/promises';
|
||||
import {
|
||||
mkdtemp,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DisconnectReason } from '@whiskeysockets/baileys';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
WhatsappConfigStore,
|
||||
deriveWhatsappBotId,
|
||||
|
|
@ -25,6 +36,87 @@ import {
|
|||
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
|
||||
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function within(promise, timeoutMs, message) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function committedArtifact(t, {
|
||||
suffix,
|
||||
fileName = 'result.txt',
|
||||
content = 'WhatsApp result file',
|
||||
} = {}) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-whatsapp-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const sessionId = `session-whatsapp-artifact-${suffix}`;
|
||||
const rpcId = `rpc-whatsapp-artifact-${suffix}`;
|
||||
let nextId = 0;
|
||||
const ids = [];
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => {
|
||||
const id = `${suffix}-${++nextId}`;
|
||||
ids.push(id);
|
||||
return id;
|
||||
},
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
return {
|
||||
artifact: registry.take(sessionId, 1)[0],
|
||||
deliveryKey: ids[1],
|
||||
};
|
||||
}
|
||||
|
||||
function artifactState(sessionId = 'session-whatsapp-artifact') {
|
||||
const seen = new Set();
|
||||
return {
|
||||
hasSeen: (messageId) => seen.has(messageId),
|
||||
markSeen: async (messageId) => { seen.add(messageId); },
|
||||
sessionFor: () => sessionId,
|
||||
sessionExists: async () => true,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function linkedConfig(overrides = {}) {
|
||||
return {
|
||||
botId: deriveWhatsappBotId(ACCOUNT_JID),
|
||||
|
|
@ -376,6 +468,265 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp runtime sends result files with native metadata, quote, stable id, and upload timeout', async (t) => {
|
||||
const { artifact, deliveryKey } = await committedArtifact(t, {
|
||||
suffix: 'native-file',
|
||||
fileName: 'report.txt',
|
||||
content: 'native WhatsApp artifact',
|
||||
});
|
||||
let callbacks;
|
||||
const calls = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content, options) => {
|
||||
calls.push({ jid, content, options });
|
||||
return { key: { id: content.document ? 'file-message-1' : 'text-message-1' } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-native-file',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
assert.equal(typeof options.onArtifact, 'function');
|
||||
await options.onArtifact(artifact);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
state: artifactState(),
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => runtime.stop());
|
||||
await runtime.start();
|
||||
const inbound = {
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'native-file-1', fromMe: false },
|
||||
message: { conversation: '生成结果文件' },
|
||||
};
|
||||
|
||||
await callbacks.onMessage(inbound);
|
||||
|
||||
const textCall = calls.find((call) => call.content.text === '结果文件如下。');
|
||||
const fileCall = calls.find((call) => call.content.document);
|
||||
assert.ok(textCall);
|
||||
assert.ok(fileCall);
|
||||
assert.equal(fileCall.jid, '16505550999@s.whatsapp.net');
|
||||
assert.equal(fileCall.content.document.toString(), 'native WhatsApp artifact');
|
||||
assert.equal(fileCall.content.mimetype, 'text/plain');
|
||||
assert.equal(fileCall.content.fileName, 'report.txt');
|
||||
assert.equal(fileCall.options.quoted, inbound);
|
||||
assert.equal(fileCall.options.mediaUploadTimeoutMs, 120_000);
|
||||
assert.equal(
|
||||
fileCall.options.messageId,
|
||||
createHash('sha256').update(deliveryKey).digest('hex').slice(0, 20).toUpperCase(),
|
||||
);
|
||||
assert.equal(fileCall.options.messageId.length, 20);
|
||||
assert.equal(calls.indexOf(textCall) < calls.indexOf(fileCall), true);
|
||||
});
|
||||
|
||||
test('WhatsApp suppresses a self-chat file echo that arrives before the provider ACK', async (t) => {
|
||||
const { artifact } = await committedArtifact(t, {
|
||||
suffix: 'early-self-file-echo',
|
||||
fileName: 'self-report.txt',
|
||||
content: 'self chat artifact',
|
||||
});
|
||||
let callbacks;
|
||||
let echoTask;
|
||||
let askCount = 0;
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content, options = {}) => {
|
||||
sent.push({ jid, content, options });
|
||||
if (content.document) {
|
||||
echoTask = callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: options.messageId, fromMe: true },
|
||||
message: {
|
||||
documentMessage: {
|
||||
fileName: content.fileName,
|
||||
mimetype: content.mimetype,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { key: { id: options.messageId } };
|
||||
}
|
||||
return { key: { id: `text-${sent.length}` } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-early-self-file-echo',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
askCount += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
state: artifactState('session-whatsapp-early-self-file-echo'),
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => runtime.stop());
|
||||
await runtime.start();
|
||||
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'self-file-request-1', fromMe: true },
|
||||
message: { conversation: '生成结果文件' },
|
||||
});
|
||||
await echoTask;
|
||||
|
||||
assert.equal(askCount, 1);
|
||||
assert.equal(sent.filter(({ content }) => content.document).length, 1);
|
||||
assert.equal(sent.some(({ content }) => content.text === '目前支持文字和图片消息。'), false);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime treats a lost file-send response as uncertain delivery', async (t) => {
|
||||
const { artifact } = await committedArtifact(t, {
|
||||
suffix: 'uncertain-file',
|
||||
fileName: 'uncertain.txt',
|
||||
});
|
||||
let callbacks;
|
||||
const warnings = [];
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (_jid, content) => {
|
||||
sent.push(content);
|
||||
if (content.document) return new Promise(() => {});
|
||||
return { key: { id: `text-${sent.length}` } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-uncertain-file',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '已生成文件。';
|
||||
},
|
||||
},
|
||||
state: artifactState('session-whatsapp-uncertain'),
|
||||
logger: {
|
||||
warn: (...args) => warnings.push(args.join(' ')),
|
||||
error() {},
|
||||
},
|
||||
mediaUploadTimeoutMs: 20,
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
t.after(() => runtime.stop());
|
||||
await runtime.start();
|
||||
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'uncertain-file-1', fromMe: false },
|
||||
message: { conversation: '生成文件' },
|
||||
});
|
||||
|
||||
assert.equal(sent.filter((content) => content.document).length, 1);
|
||||
assert.equal(warnings.some((warning) => warning.includes('artifact-delivery-uncertain')), true);
|
||||
assert.equal(sent.some((content) => content.text?.includes('发送结果未能确认')), true);
|
||||
});
|
||||
|
||||
test('stopping WhatsApp aborts a pending upload without another file or failure notice', async (t) => {
|
||||
const first = await committedArtifact(t, {
|
||||
suffix: 'cancel-first',
|
||||
fileName: 'first.txt',
|
||||
});
|
||||
const second = await committedArtifact(t, {
|
||||
suffix: 'cancel-second',
|
||||
fileName: 'second.txt',
|
||||
});
|
||||
let callbacks;
|
||||
const uploadStarted = deferred();
|
||||
const uploadResponse = deferred();
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (_jid, content) => {
|
||||
sent.push(content);
|
||||
if (content.document) {
|
||||
uploadStarted.resolve();
|
||||
return uploadResponse.promise;
|
||||
}
|
||||
return { key: { id: `text-${sent.length}` } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-cancel-upload',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(first.artifact);
|
||||
await options.onArtifact(second.artifact);
|
||||
return '结果文件如下。';
|
||||
},
|
||||
},
|
||||
state: artifactState('session-whatsapp-cancel'),
|
||||
logger: { warn() {}, error() {} },
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
const processing = callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancel-upload-1', fromMe: false },
|
||||
message: { conversation: '生成两个文件' },
|
||||
});
|
||||
await uploadStarted.promise;
|
||||
|
||||
await within(runtime.stop(), 500, 'WhatsApp runtime did not stop a pending upload promptly');
|
||||
await within(processing, 500, 'WhatsApp message processing remained blocked after stop');
|
||||
uploadResponse.resolve({ key: { id: 'late-provider-response' } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(
|
||||
sent.filter((content) => content.document).map((content) => content.fileName),
|
||||
['first.txt'],
|
||||
);
|
||||
assert.equal(sent.some((content) => content.text?.includes('暂时未能发送')), false);
|
||||
assert.equal(sent.some((content) => content.text === '消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
|
||||
let callbacks;
|
||||
let askCount = 0;
|
||||
|
|
|
|||
103
test/delivery-receipt.test.mjs
Normal file
103
test/delivery-receipt.test.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
artifactOutcomeForError,
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../src/channels/shared/semantic/delivery.mjs';
|
||||
|
||||
test('DeliveryReceipt validates and freezes the shared versioned contract', () => {
|
||||
const receipt = createDeliveryReceipt({
|
||||
deliveryId: 'delivery-1',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file', 'om-file'],
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-1',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
|
||||
});
|
||||
assert.equal(Object.isFrozen(receipt), true);
|
||||
assert.equal(Object.isFrozen(receipt.providerMessageIds), true);
|
||||
assert.equal(Object.isFrozen(receipt.artifacts[0]), true);
|
||||
assert.throws(
|
||||
() => createDeliveryReceipt({
|
||||
deliveryId: 'delivery-invalid',
|
||||
presentation: 'feishu-file',
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'maybe' }],
|
||||
}),
|
||||
/artifact outcome/,
|
||||
);
|
||||
});
|
||||
|
||||
test('artifact failures distinguish policy rejection, retryable failure, and uncertain delivery', () => {
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-permission-required' }), 'rejected');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-too-large' }), 'rejected');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-unavailable' }), 'rejected');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-rate-limited' }), 'failed');
|
||||
assert.equal(artifactOutcomeForError(new Error('network unavailable')), 'failed');
|
||||
assert.equal(artifactOutcomeForError({ code: 'artifact-delivery-uncertain' }), 'unknown');
|
||||
|
||||
assert.deepEqual(createArtifactFailureReceipt({
|
||||
artifactId: 'artifact-uncertain',
|
||||
deliveryId: 'delivery-uncertain',
|
||||
error: { code: 'artifact-delivery-uncertain' },
|
||||
}).artifacts, [{
|
||||
artifactId: 'artifact-uncertain',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('provider message ids are collected only from explicit message-id fields', () => {
|
||||
assert.deepEqual(providerMessageIdsFor({ message_id: 42 }), ['42']);
|
||||
assert.deepEqual(providerMessageIdsFor({ key: { id: 'wamid-one' } }), ['wamid-one']);
|
||||
assert.deepEqual(providerMessageIdsFor({ ts: '123.456' }), ['123.456']);
|
||||
assert.deepEqual(providerMessageIdsFor({
|
||||
providerMessageIds: ['first', 'second', 'first', '', null],
|
||||
}), ['first', 'second']);
|
||||
assert.deepEqual(providerMessageIdsFor({ processQueryKey: 'not-a-message-id' }), []);
|
||||
assert.deepEqual(providerMessageIdsFor({ files: [{ id: 'not-a-message-id' }] }), []);
|
||||
});
|
||||
|
||||
test('text and multiple artifact attempts merge into one authoritative receipt', () => {
|
||||
const text = createDeliveryReceipt({
|
||||
deliveryId: 'turn-message',
|
||||
presentation: 'feishu-cardkit',
|
||||
providerMessageIds: ['om-card'],
|
||||
});
|
||||
const sent = createDeliveryReceipt({
|
||||
deliveryId: 'file-1',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
|
||||
});
|
||||
const failed = createArtifactFailureReceipt({
|
||||
artifactId: 'artifact-2',
|
||||
deliveryId: 'file-2',
|
||||
error: { code: 'artifact-rate-limited' },
|
||||
providerMessageIds: ['om-notice'],
|
||||
});
|
||||
|
||||
assert.deepEqual(mergeDeliveryReceipts({
|
||||
deliveryId: 'turn-message',
|
||||
presentation: 'feishu-text-and-files',
|
||||
receipts: [text, sent, failed],
|
||||
}), {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'turn-message',
|
||||
presentation: 'feishu-text-and-files',
|
||||
providerMessageIds: ['om-card', 'om-file', 'om-notice'],
|
||||
artifacts: [
|
||||
{ artifactId: 'artifact-1', outcome: 'sent' },
|
||||
{ artifactId: 'artifact-2', outcome: 'failed', reason: 'artifact-rate-limited' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -35,7 +35,12 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
await plugin.apply(ctx, config);
|
||||
|
||||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
|
||||
assert.deepEqual(inject, [
|
||||
'connection',
|
||||
'credentials',
|
||||
'webServer',
|
||||
'typertGateway',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],
|
||||
|
|
|
|||
391
test/outbound-artifact.test.mjs
Normal file
391
test/outbound-artifact.test.mjs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
installOutboundArtifactTool,
|
||||
materializeOutboundArtifact,
|
||||
readExactArtifactFile,
|
||||
releaseOutboundArtifact,
|
||||
} from '../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
async function fixture(t) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-artifact-workspace-'));
|
||||
const outside = await mkdtemp(join(tmpdir(), 'dsh-im-artifact-outside-'));
|
||||
t.after(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `artifact-id-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-artifact', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
{ type: 'user/message', data: { turn: 7, source: { rpcId: 'rpc-artifact' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
return { workspace, outside, registry, agent };
|
||||
}
|
||||
|
||||
function execution(agent, callId, overrides = {}) {
|
||||
return {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
agent,
|
||||
callId,
|
||||
rootCallId: callId,
|
||||
token: Symbol(callId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function execute(tool, args, exec, result = { isError: false }) {
|
||||
const value = await tool.definition.execute(args, exec);
|
||||
tool.onResult(exec, result);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function takeFile(registry, sessionId = 'session-artifact', turn = 7) {
|
||||
const [artifact] = registry.take(sessionId, turn);
|
||||
assert.ok(artifact);
|
||||
const file = await materializeOutboundArtifact(artifact);
|
||||
return { artifact, file };
|
||||
}
|
||||
|
||||
test('an existing file can be sent directly without recreation', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'existing.txt'), 'already here');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
const result = await execute(tool, { path: 'existing.txt' }, execution(fx.agent, 'existing'));
|
||||
|
||||
assert.deepEqual(result, {
|
||||
artifactId: 'artifact-id-1',
|
||||
fileName: 'existing.txt',
|
||||
size: 12,
|
||||
});
|
||||
const { artifact, file } = await takeFile(fx.registry);
|
||||
assert.equal(file.bytes.toString(), 'already here');
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('absolute outside-workspace paths and symbolic links are delivered normally', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
const outsidePath = join(fx.outside, 'outside.txt');
|
||||
await writeFile(outsidePath, 'outside content');
|
||||
await symlink(outsidePath, join(fx.workspace, 'linked.txt'));
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await execute(tool, { path: outsidePath }, execution(fx.agent, 'outside'));
|
||||
await execute(tool, { path: 'linked.txt' }, execution(fx.agent, 'linked'));
|
||||
|
||||
const artifacts = fx.registry.take('session-artifact', 7);
|
||||
assert.equal(artifacts.length, 2);
|
||||
const files = await Promise.all(artifacts.map((artifact) => materializeOutboundArtifact(artifact)));
|
||||
assert.deepEqual(files.map((file) => file.fileName), ['outside.txt', 'linked.txt']);
|
||||
assert.deepEqual(files.map((file) => file.bytes.toString()), ['outside content', 'outside content']);
|
||||
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('empty, sensitive-looking, and extension-mismatched files are not filtered', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'empty.txt'), '');
|
||||
await writeFile(
|
||||
join(fx.workspace, '.env'),
|
||||
'PASSWORD=example\n-----BEGIN PRIVATE KEY-----\nexample\n',
|
||||
);
|
||||
await writeFile(join(fx.workspace, 'plain.png'), 'not a PNG signature');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await execute(tool, { path: 'empty.txt' }, execution(fx.agent, 'empty'));
|
||||
await execute(tool, { path: '.env' }, execution(fx.agent, 'sensitive-looking'));
|
||||
await execute(tool, { path: 'plain.png' }, execution(fx.agent, 'extension-mismatch'));
|
||||
|
||||
const artifacts = fx.registry.take('session-artifact', 7);
|
||||
assert.equal(artifacts.length, 3);
|
||||
const files = await Promise.all(artifacts.map((artifact) => materializeOutboundArtifact(artifact)));
|
||||
assert.deepEqual(files.map((file) => file.size), [0, 53, 19]);
|
||||
assert.equal(files[1].bytes.toString().startsWith('PASSWORD='), true);
|
||||
assert.equal(files[2].bytes.toString(), 'not a PNG signature');
|
||||
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('the registry does not deduplicate or impose project-level file-count quotas', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'same.txt'), 'same');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await execute(tool, { path: 'same.txt' }, execution(fx.agent, `same-${index}`));
|
||||
}
|
||||
|
||||
const artifacts = fx.registry.take('session-artifact', 7);
|
||||
assert.equal(artifacts.length, 12);
|
||||
assert.equal(new Set(artifacts.map((artifact) => artifact.artifactId)).size, 12);
|
||||
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('registration keeps a private snapshot without changing or deleting the source file', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
const path = join(fx.workspace, 'mutable.txt');
|
||||
await writeFile(path, 'first value');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path }, execution(fx.agent, 'snapshot'));
|
||||
await writeFile(path, 'second value');
|
||||
|
||||
const { artifact, file } = await takeFile(fx.registry);
|
||||
assert.equal(file.bytes.toString(), 'first value');
|
||||
releaseOutboundArtifact(artifact);
|
||||
assert.equal(await readFile(path, 'utf8'), 'second value');
|
||||
});
|
||||
|
||||
test('missing paths and directories fail because they cannot be uploaded as files', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await assert.rejects(
|
||||
tool.definition.execute({ path: 'missing.txt' }, execution(fx.agent, 'missing')),
|
||||
(error) => error.code === 'artifact-unavailable',
|
||||
);
|
||||
await assert.rejects(
|
||||
tool.definition.execute({ path: '.' }, execution(fx.agent, 'directory')),
|
||||
(error) => error.code === 'artifact-not-file',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed authoritative tool result is not delivered', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'failed.txt'), 'failed');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
|
||||
await execute(
|
||||
tool,
|
||||
{ path: 'failed.txt' },
|
||||
execution(fx.agent, 'failed'),
|
||||
{ isError: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
});
|
||||
|
||||
test('Code Mode waits for the outer execution result before delivery', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'nested.txt'), 'nested');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
const rootToken = Symbol('root');
|
||||
const nested = execution(fx.agent, 'nested', {
|
||||
parent: rootToken,
|
||||
rootCallId: 'root-call',
|
||||
});
|
||||
|
||||
await execute(tool, { path: 'nested.txt' }, nested);
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
tool.onResult({
|
||||
name: 'run_code',
|
||||
callId: 'root-call',
|
||||
rootCallId: 'root-call',
|
||||
token: rootToken,
|
||||
agent: fx.agent,
|
||||
}, { isError: false });
|
||||
|
||||
const { artifact, file } = await takeFile(fx.registry);
|
||||
assert.equal(file.bytes.toString(), 'nested');
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('Session and Turn ownership routes files only to the originating conversation', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'owned.txt'), 'owned');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'owned.txt' }, execution(fx.agent, 'owned'));
|
||||
|
||||
assert.deepEqual(fx.registry.take('other-session', 7), []);
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 8), []);
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
assert.ok(artifact);
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('a completed Turn without a channel consumer releases its unclaimed snapshot', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'unclaimed.txt'), 'unclaimed');
|
||||
const artifact = await fx.registry.stage(
|
||||
{ path: 'unclaimed.txt' },
|
||||
execution(fx.agent, 'unclaimed'),
|
||||
);
|
||||
fx.registry.commit(artifact);
|
||||
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/end', data: { turn: 7 } },
|
||||
);
|
||||
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('a channel consumer keeps its completed Turn available until polling claims it', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'consumer.txt'), 'consumer');
|
||||
const closeConsumer = fx.registry.openConsumer('session-artifact', 'rpc-artifact');
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
);
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'user/message', data: { source: { rpcId: 'rpc-artifact' } } },
|
||||
);
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'consumer.txt' }, execution(fx.agent, 'consumer'));
|
||||
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/end', data: { turn: 7 } },
|
||||
);
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
assert.ok(artifact);
|
||||
closeConsumer();
|
||||
|
||||
const file = await materializeOutboundArtifact(artifact);
|
||||
assert.equal(file.bytes.toString(), 'consumer');
|
||||
releaseOutboundArtifact(artifact);
|
||||
});
|
||||
|
||||
test('closing an unclaimed channel consumer releases its completed Turn', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'consumer-closed.txt'), 'consumer closed');
|
||||
const closeConsumer = fx.registry.openConsumer('session-artifact', 'rpc-artifact');
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/start', data: { turn: 7 } },
|
||||
);
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'user/message', data: { source: { rpcId: 'rpc-artifact' } } },
|
||||
);
|
||||
const artifact = await fx.registry.stage(
|
||||
{ path: 'consumer-closed.txt' },
|
||||
execution(fx.agent, 'consumer-closed'),
|
||||
);
|
||||
fx.registry.commit(artifact);
|
||||
fx.registry.observeSessionEvent(
|
||||
{ id: 'session-artifact' },
|
||||
{ type: 'turn/end', data: { turn: 7 } },
|
||||
);
|
||||
|
||||
closeConsumer();
|
||||
|
||||
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('aborting the owning delivery releases its unhanded snapshot', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'abort.txt'), 'abort');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'abort.txt' }, execution(fx.agent, 'abort'));
|
||||
const controller = new AbortController();
|
||||
const [artifact] = fx.registry.take('session-artifact', 7, { signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit release also cleans a snapshot that was already claimed', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'handoff-failed.txt'), 'handoff failed');
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(tool, { path: 'handoff-failed.txt' }, execution(fx.agent, 'handoff-failed'));
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
|
||||
fx.registry.release(artifact);
|
||||
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup requested during materialization runs as soon as the read completes', async (t) => {
|
||||
const fx = await fixture(t);
|
||||
await writeFile(join(fx.workspace, 'concurrent-cleanup.txt'), Buffer.alloc(1024 * 1024, 7));
|
||||
const tool = createOutboundArtifactTool({ registry: fx.registry });
|
||||
await execute(
|
||||
tool,
|
||||
{ path: 'concurrent-cleanup.txt' },
|
||||
execution(fx.agent, 'concurrent-cleanup'),
|
||||
);
|
||||
const [artifact] = fx.registry.take('session-artifact', 7);
|
||||
|
||||
const materializing = materializeOutboundArtifact(artifact);
|
||||
fx.registry.clear();
|
||||
const file = await materializing;
|
||||
|
||||
assert.equal(file.size, 1024 * 1024);
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error.code === 'artifact-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
test('exact reads have no independent timeout and preserve caller cancellation', async () => {
|
||||
let read = 0;
|
||||
const handle = {
|
||||
async read(_buffer, _offset, _length, position) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (position === 0 && read++ === 0) return { bytesRead: 1 };
|
||||
return { bytesRead: 0 };
|
||||
},
|
||||
};
|
||||
const bytes = await readExactArtifactFile(handle, 1);
|
||||
assert.equal(bytes.byteLength, 1);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled'));
|
||||
await assert.rejects(
|
||||
readExactArtifactFile(handle, 1, { signal: controller.signal }),
|
||||
/cancelled/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Host installer always exposes the tool and explicitly permits existing files', () => {
|
||||
let definition;
|
||||
const listeners = new Map();
|
||||
let section;
|
||||
const installed = installOutboundArtifactTool({
|
||||
tools: { register(value) { definition = value; } },
|
||||
on(name, value) { listeners.set(name, value); },
|
||||
systemPrompt: { section(value) { section = value; } },
|
||||
}, { registry: new OutboundArtifactRegistry() });
|
||||
|
||||
assert.equal(installed, true);
|
||||
assert.equal(definition.name, OUTBOUND_ARTIFACT_TOOL);
|
||||
assert.match(definition.description, /Existing and newly created files are both valid/);
|
||||
assert.match(section.text, /Existing files can be sent directly/);
|
||||
assert.equal(typeof listeners.get('tools/result'), 'function');
|
||||
assert.equal(typeof listeners.get('session/event'), 'function');
|
||||
assert.equal(typeof listeners.get('session/disposed'), 'function');
|
||||
assert.equal(listeners.has('agent/inbox/claimed'), false);
|
||||
assert.equal(listeners.has('system-prompt/assemble'), false);
|
||||
assert.equal(installOutboundArtifactTool({}), false);
|
||||
});
|
||||
|
|
@ -52,6 +52,34 @@ async function fixture(t) {
|
|||
return { root, defaultWorkspace, alternateWorkspace, path: join(root, 'workspaces.json') };
|
||||
}
|
||||
|
||||
test('workspace asks collect result files without an explicit Gate', async () => {
|
||||
const observed = [];
|
||||
const harness = {
|
||||
async sessionExists() { return true; },
|
||||
async ask(_sessionId, _text, options) {
|
||||
observed.push(options);
|
||||
await options?.onArtifact?.({ artifactId: 'artifact-one' });
|
||||
return 'answer';
|
||||
},
|
||||
};
|
||||
const state = { sessionFor: () => 'session-existing' };
|
||||
const askOptions = { timeoutMs: 1234 };
|
||||
|
||||
assert.deepEqual(await askInWorkspaceSession({
|
||||
harness,
|
||||
state,
|
||||
key: 'default',
|
||||
text: 'file request',
|
||||
askOptions,
|
||||
}), {
|
||||
sessionId: 'session-existing',
|
||||
answer: 'answer',
|
||||
artifacts: [{ artifactId: 'artifact-one' }],
|
||||
});
|
||||
assert.notEqual(observed[0], askOptions);
|
||||
assert.equal(typeof observed[0].onArtifact, 'function');
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue