mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
Merge upstream v4.5.0 (xmanrui/main) into fix/non-vision-model-image-file-fallback
- CHANGELOG: keep the image-fallback entry under Unreleased alongside the new 4.5.0 section - harness-client: upstream renameSession coexists with the image-rejection fallback retry path - lib/index.js rebuilt from merged sources
This commit is contained in:
commit
994df6e1e3
21 changed files with 557 additions and 235 deletions
|
|
@ -88,6 +88,10 @@ function fixture(channel, { contextEnhancement, onAsk } = {}) {
|
|||
const harness = {
|
||||
ensureRunning: async () => { calls.push(['ensureRunning']); },
|
||||
createSession: async () => { calls.push(['createSession']); return 'session-existing'; },
|
||||
renameSession: async (sessionId, title) => {
|
||||
calls.push(['renameSession', sessionId, title]);
|
||||
return { title, seq: 0 };
|
||||
},
|
||||
sessionExists: async (id) => { calls.push(['sessionExists', id]); return true; },
|
||||
hasActiveTurn: async () => false,
|
||||
isSessionRunning: async () => false,
|
||||
|
|
@ -281,6 +285,7 @@ for (const channel of CHANNELS) {
|
|||
await baseline.bridge.accept(baseline.event(1, undefined, { kind, media, poisonName: true }));
|
||||
assert.equal(baseline.prompts.length, 1);
|
||||
assert.equal(baseline.sourceReads, 0);
|
||||
assert.equal(baseline.calls.some(([operation]) => operation === 'renameSession'), false);
|
||||
for (const contextEnhancement of variants) {
|
||||
const current = fixture(channel, { contextEnhancement });
|
||||
await current.bridge.accept(current.event(1, undefined, { kind, media, poisonName: true }));
|
||||
|
|
@ -308,6 +313,10 @@ for (const channel of CHANNELS) {
|
|||
assert.deepEqual(sourceOf(current.prompts[0]), expected);
|
||||
assert.ok(current.prompts[0].endsWith('\n\nhello'));
|
||||
assert.doesNotMatch(current.prompts[0], /source_guidance|private-token|private-secret/);
|
||||
assert.deepEqual(
|
||||
current.calls.filter(([operation]) => operation === 'renameSession'),
|
||||
[['renameSession', 'session-existing', 'hello']],
|
||||
);
|
||||
// Explicit null represents a provider event that does not include a nickname.
|
||||
await current.bridge.accept(current.event(2, 'missing name', { kind, name: null }));
|
||||
assert.equal(Object.hasOwn(sourceOf(current.prompts[1]), 'senderName'), false);
|
||||
|
|
@ -355,7 +364,18 @@ for (const channel of CHANNELS) {
|
|||
assert.ok(enhanced.endsWith(`\n\n${original}`));
|
||||
}
|
||||
assert.deepEqual(enabled.calls.filter(([op]) => op === 'file'), plain.calls.filter(([op]) => op === 'file'));
|
||||
assert.deepEqual(withoutPrompt(enabled.calls), withoutPrompt(plain.calls), 'enhancement adds no provider or Harness requests');
|
||||
const expectedTitle = media === 'file' && ['feishu', 'dingtalk'].includes(channel)
|
||||
? 'report.txt'
|
||||
: 'caption';
|
||||
assert.deepEqual(
|
||||
enabled.calls.filter(([op]) => op === 'renameSession'),
|
||||
[['renameSession', 'session-existing', expectedTitle]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
withoutPrompt(enabled.calls.filter(([op]) => op !== 'renameSession')),
|
||||
withoutPrompt(plain.calls),
|
||||
'enhancement only adds the initial title request',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -446,7 +466,10 @@ for (const channel of CHANNELS) {
|
|||
await current.bridge.accept(current.event(1, 'hello'));
|
||||
if (config.direct.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
|
||||
else if (config.direct.guidance) assert.equal(current.prompts[0], '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>\n\nhello');
|
||||
else assert.equal(current.prompts[0], 'hello');
|
||||
else {
|
||||
assert.equal(current.prompts[0], 'hello');
|
||||
assert.equal(current.calls.some(([operation]) => operation === 'renameSession'), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ test('in-process RPC preserves IDs, payloads, namespace receivers and errors', a
|
|||
const calls = [];
|
||||
const apiProxy = {};
|
||||
const methods = ['host.describe', 'workspace.list', 'workspace.create', 'session.list',
|
||||
'session.create', 'session.history', 'session.prompt', 'session.cancel',
|
||||
'session.create', 'session.history', 'session.prompt', 'session.rename', 'session.cancel',
|
||||
'session.models', 'session.selectModel', 'llm.models'];
|
||||
for (const method of methods) {
|
||||
const [domain, action] = method.split('.');
|
||||
|
|
@ -191,6 +191,29 @@ test('explicit baseUrl still selects the existing HTTP transport', async () => {
|
|||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test('Session rename uses the public Harness RPC and validates local input', async () => {
|
||||
const calls = [];
|
||||
const success = (rpcId, value) => ({ rpcId, result: { ok: true, value } });
|
||||
const client = localClient({
|
||||
host: { describe: ({ rpcId }) => success(rpcId, {}) },
|
||||
sessions: {
|
||||
rename: ({ rpcId, payload }) => {
|
||||
calls.push(payload);
|
||||
return success(rpcId, { title: payload.title, seq: 3 });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await client.renameSession('session', '查询订单'), {
|
||||
title: '查询订单',
|
||||
seq: 3,
|
||||
});
|
||||
assert.deepEqual(calls, [{ sessionId: 'session', title: '查询订单' }]);
|
||||
await assert.rejects(client.renameSession('', 'title'), TypeError);
|
||||
await assert.rejects(client.renameSession('session', ' '), TypeError);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('history reading uses only the existing read RPC in both Host connection modes', async () => {
|
||||
const page = { events: [], hasMore: false };
|
||||
for (const mode of ['local', 'http']) {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ test('modern adapter maps the narrow legacy API without changing HarnessClient',
|
|||
};
|
||||
}
|
||||
if (endpoint === 'session/prompt') return { accepted: true };
|
||||
if (endpoint === 'session/rename') {
|
||||
return { title: request.args.request.title, seq: 9 };
|
||||
}
|
||||
if (endpoint === 'session/cancel') return { accepted: true };
|
||||
if (endpoint === 'session/selectModel') return { selected: request.args.request };
|
||||
throw new Error(`unexpected invoke ${endpoint}`);
|
||||
|
|
@ -157,6 +160,16 @@ test('modern adapter maps the narrow legacy API without changing HarnessClient',
|
|||
const promptCall = calls.find((call) => call.namespace === 'session' && call.method === 'prompt');
|
||||
assert.equal(promptCall.args.request.requestId, 'prompt-correlation');
|
||||
assert.equal(Object.hasOwn(promptCall.args.request, 'rpcId'), false);
|
||||
|
||||
const renamed = await first.apiProxy.sessions.rename({
|
||||
rpcId: 'rename-correlation',
|
||||
payload: { sessionId: 'session', title: '订单查询' },
|
||||
});
|
||||
assert.deepEqual(renamed.result.value, { title: '订单查询', seq: 9 });
|
||||
const renameCall = calls.find((call) => call.namespace === 'session' && call.method === 'rename');
|
||||
assert.deepEqual(renameCall.args, {
|
||||
request: { sessionId: 'session', title: '订单查询' },
|
||||
});
|
||||
});
|
||||
|
||||
test('modern adapter preserves Typert business failures as Harness RPC errors', async () => {
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ test('the next message continues the bound Session without creating a new one',
|
|||
const state = memoryState({ conversation: 'session-old' });
|
||||
const asked = [];
|
||||
let createCalls = 0;
|
||||
let renameCalls = 0;
|
||||
const scope = createBotWorkspaceScope({
|
||||
async adoptWorkspaceSession(sessionId) {
|
||||
return { sessionId, workspace: alternateWorkspace };
|
||||
|
|
@ -184,6 +185,7 @@ test('the next message continues the bound Session without creating a new one',
|
|||
createCalls += 1;
|
||||
return 'session-created-unexpectedly';
|
||||
},
|
||||
async renameSession() { renameCalls += 1; },
|
||||
async ask(sessionId, text) {
|
||||
asked.push({ sessionId, text });
|
||||
return 'continued answer';
|
||||
|
|
@ -201,9 +203,48 @@ test('the next message continues the bound Session without creating a new one',
|
|||
assert.deepEqual(reply, { sessionId: 'session-target', answer: 'continued answer' });
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-target', text: 'continue here' }]);
|
||||
assert.equal(createCalls, 0);
|
||||
assert.equal(renameCalls, 0);
|
||||
});
|
||||
|
||||
test('a first prompt and model switch share one binding without holding the lock during ask', async () => {
|
||||
test('a newly created enhanced Session is renamed from the first prompt before ask', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_title');
|
||||
const state = memoryState();
|
||||
const calls = [];
|
||||
const scope = createBotWorkspaceScope({
|
||||
async createSession() {
|
||||
calls.push(['create']);
|
||||
return 'session-titled';
|
||||
},
|
||||
async renameSession(sessionId, title) {
|
||||
calls.push(['rename', sessionId, title]);
|
||||
return { title, seq: 0 };
|
||||
},
|
||||
async ask(sessionId, prompt) {
|
||||
calls.push(['ask', sessionId, prompt]);
|
||||
return 'answer';
|
||||
},
|
||||
}, { botId: 'bot_title', workspaces, state });
|
||||
|
||||
const result = await askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'conversation',
|
||||
text: '请检查今天的订单',
|
||||
content: '<dsh_im_source>{"channel":"qq"}</dsh_im_source>\n\n请检查今天的订单',
|
||||
contextEnhanced: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: 'session-titled', answer: 'answer' });
|
||||
assert.deepEqual(calls, [
|
||||
['create'],
|
||||
['rename', 'session-titled', '请检查今天的订单'],
|
||||
['ask', 'session-titled', '<dsh_im_source>{"channel":"qq"}</dsh_im_source>\n\n请检查今天的订单'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an ordinary first prompt keeps DSH title handling while model switch shares its binding', async () => {
|
||||
const state = memoryState();
|
||||
const askStarted = deferred();
|
||||
const releaseAsk = deferred();
|
||||
|
|
@ -226,6 +267,9 @@ test('a first prompt and model switch share one binding without holding the lock
|
|||
workspaceSession(sessionId) {
|
||||
return {
|
||||
async sessionExists() { return true; },
|
||||
async renameTitle(title) {
|
||||
calls.push(['renameTitle', sessionId, title]);
|
||||
},
|
||||
async ask(text) {
|
||||
calls.push(['ask', sessionId, text]);
|
||||
askStarted.resolve();
|
||||
|
|
|
|||
57
test/session-title.test.mjs
Normal file
57
test/session-title.test.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
INITIAL_SESSION_TITLE_MAX_BYTES,
|
||||
initialSessionTitle,
|
||||
} from '../src/channels/shared/session-title.mjs';
|
||||
|
||||
test('initial Session titles use original text instead of enhanced context', () => {
|
||||
const content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<dsh_im_source>{"channel":"qq"}</dsh_im_source>\n\n'
|
||||
+ '<dsh_im_source_guidance>reply formally</dsh_im_source_guidance>',
|
||||
},
|
||||
{ type: 'text', text: 'enhanced copy' },
|
||||
];
|
||||
|
||||
assert.equal(initialSessionTitle({
|
||||
text: ' 帮我查询\n今天的订单 ',
|
||||
content,
|
||||
}), '帮我查询 今天的订单');
|
||||
});
|
||||
|
||||
test('initial Session titles strip only leading dsh-im context from structured prompts', () => {
|
||||
const content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<dsh_im_source>{"channel":"feishu"}</dsh_im_source>\n\n'
|
||||
+ '<dsh_im_source_guidance>private guidance</dsh_im_source_guidance>',
|
||||
},
|
||||
{ type: 'text', text: '请分析这张图片。' },
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
];
|
||||
|
||||
const title = initialSessionTitle({ content });
|
||||
assert.equal(title, '请分析这张图片。');
|
||||
assert.doesNotMatch(title, /dsh_im_source|private guidance/u);
|
||||
|
||||
assert.equal(initialSessionTitle({
|
||||
text: '<dsh_im_source>这是用户正文</dsh_im_source>',
|
||||
}), '<dsh_im_source>这是用户正文</dsh_im_source>');
|
||||
});
|
||||
|
||||
test('initial Session titles truncate safely within the UTF-8 byte budget', () => {
|
||||
const title = initialSessionTitle({ text: '订单'.repeat(30) });
|
||||
assert.equal(title.endsWith('…'), true);
|
||||
assert.equal(Buffer.byteLength(title, 'utf8') <= INITIAL_SESSION_TITLE_MAX_BYTES, true);
|
||||
});
|
||||
|
||||
test('initial Session titles fall back to a safe inbound filename', () => {
|
||||
assert.equal(initialSessionTitle({
|
||||
text: '',
|
||||
files: [{ name: '../reports/报价单.xlsx' }],
|
||||
}), '报价单.xlsx');
|
||||
assert.equal(initialSessionTitle({ text: '', content: [], files: [] }), null);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue