mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 06:33:17 +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
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],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue