feat: add native result-file delivery

This commit is contained in:
xmanrui 2026-08-23 20:02:06 +08:00
parent 2803bbcbab
commit d472dd5a75
65 changed files with 10205 additions and 255 deletions

View 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',
]);
});

View file

@ -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' };

View 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();
});

View file

@ -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],