mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00:50 +08:00
fix: preserve session titles without context enhancement
This commit is contained in:
parent
e8c9817598
commit
d044f1b0aa
17 changed files with 527 additions and 223 deletions
417
lib/index.js
417
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -221,6 +221,9 @@ class ModernHarnessApi {
|
|||
{ request: { requestId: request.rpcId, ...request.payload } },
|
||||
signal,
|
||||
)),
|
||||
rename: (request, signal) => rpcResult(request, () => this.#invoke(
|
||||
'session', 'rename', { request: request.payload }, signal,
|
||||
)),
|
||||
cancel: (request, signal) => rpcResult(request, () => this.#invoke(
|
||||
'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -996,13 +996,16 @@ export class DingtalkHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'dingtalk',
|
||||
senderId: sender,
|
||||
senderName: message.senderNick,
|
||||
conversationTitle: message.conversationTitle,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
if (typeof this.#api.createAiCard === 'function'
|
||||
&& typeof this.#api.updateAiCard === 'function'
|
||||
|
|
@ -1021,7 +1024,9 @@ export class DingtalkHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -3293,11 +3293,14 @@ export class FeishuHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'feishu',
|
||||
senderId: senderOpenId(event),
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
if (!this.#channel?.stream) {
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
|
|
@ -3306,6 +3309,7 @@ export class FeishuHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||
|
|
@ -3375,6 +3379,7 @@ export class FeishuHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions,
|
||||
|
|
@ -3438,6 +3443,7 @@ export class FeishuHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||
|
|
|
|||
|
|
@ -840,12 +840,15 @@ export class QqHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'qq',
|
||||
senderId: sender,
|
||||
senderName: message.kind === 'group' ? message.senderName : undefined,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
// QQ stream_messages can acknowledge a final frame without rendering it in
|
||||
// some C2C clients. Standard Markdown delivery is the reliable reply path.
|
||||
|
|
@ -860,7 +863,9 @@ export class QqHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -1211,6 +1211,11 @@ export function createBotWorkspaceScope(
|
|||
readHistory(...args) {
|
||||
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
||||
},
|
||||
...(typeof target.renameSession === 'function' ? {
|
||||
renameTitle(...args) {
|
||||
return invokeStartedSessionMutation('renameSession', args, 'title rename');
|
||||
},
|
||||
} : {}),
|
||||
selectModel(...args) {
|
||||
return invokeCurrentSession('selectSessionModel', args, 'model selection');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -911,6 +911,12 @@ export class HarnessClient {
|
|||
return created.sessionId;
|
||||
}
|
||||
|
||||
async renameSession(sessionId, title, options = {}) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||
if (typeof title !== 'string' || !title.trim()) throw new TypeError('session title is required');
|
||||
return this.rpc('session.rename', { sessionId, title }, 30_000, options);
|
||||
}
|
||||
|
||||
async executeCommand(sessionId, line, options = {}) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||
if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
|
||||
|
|
|
|||
74
src/channels/shared/session-title.mjs
Normal file
74
src/channels/shared/session-title.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
const DEFAULT_MAX_TITLE_BYTES = 60;
|
||||
|
||||
const OSC_SEQUENCE = /(?:\u001b\]|\u009d)(?:(?!\u0007|\u001b\\)[\s\S])*(?:\u0007|\u001b\\|$)/gu;
|
||||
const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu;
|
||||
const ESC_SEQUENCE = /\u001b[@-_]/gu;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
|
||||
const DIRECTIONAL_CONTROL = /[\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
|
||||
const INJECTED_CONTEXT_PREFIX = /^(?:<dsh_im_source>[\s\S]*?<\/dsh_im_source>\s*)?(?:<dsh_im_source_guidance>[\s\S]*?<\/dsh_im_source_guidance>\s*)?/u;
|
||||
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
|
||||
function cleanTitleText(input) {
|
||||
return input
|
||||
.replace(OSC_SEQUENCE, '')
|
||||
.replace(CSI_SEQUENCE, '')
|
||||
.replace(ESC_SEQUENCE, '')
|
||||
.replace(CONTROL_CHARACTER, '')
|
||||
.replace(DIRECTIONAL_CONTROL, '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function truncateTitle(input, maxBytes = DEFAULT_MAX_TITLE_BYTES) {
|
||||
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
|
||||
const suffix = '…';
|
||||
const budget = maxBytes - Buffer.byteLength(suffix, 'utf8');
|
||||
let output = '';
|
||||
for (const { segment } of SEGMENTER.segment(input)) {
|
||||
if (Buffer.byteLength(output + segment, 'utf8') > budget) break;
|
||||
output += segment;
|
||||
}
|
||||
return `${output.trimEnd()}${suffix}`;
|
||||
}
|
||||
|
||||
function contentText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function fileDisplayName(file) {
|
||||
const value = file?.name ?? file?.filename ?? file?.fileName;
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
.replaceAll('\\', '/')
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/[\u0000-\u001f\u007f]/gu, '')
|
||||
.trim() ?? '';
|
||||
}
|
||||
|
||||
/** Build a deterministic title from the unenhanced first user message. */
|
||||
export function initialSessionTitle({ text, content, files } = {}) {
|
||||
const original = typeof text === 'string' ? cleanTitleText(text) : '';
|
||||
if (original) return truncateTitle(original);
|
||||
|
||||
// Structured image prompts may only expose their default text through content.
|
||||
// Strip only the leading blocks inserted by dsh-im; matching tags later in user
|
||||
// content remain ordinary user text.
|
||||
const visibleContent = cleanTitleText(
|
||||
contentText(content).replace(INJECTED_CONTEXT_PREFIX, ''),
|
||||
);
|
||||
if (visibleContent) return truncateTitle(visibleContent);
|
||||
|
||||
const fileName = Array.isArray(files)
|
||||
? files.map(fileDisplayName).find(Boolean)
|
||||
: '';
|
||||
const cleanFileName = fileName ? cleanTitleText(fileName) : '';
|
||||
return cleanFileName ? truncateTitle(cleanFileName) : null;
|
||||
}
|
||||
|
||||
export const INITIAL_SESSION_TITLE_MAX_BYTES = DEFAULT_MAX_TITLE_BYTES;
|
||||
|
|
@ -673,13 +673,16 @@ export class TextHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: this.#descriptor.key,
|
||||
senderId,
|
||||
senderName: message.contextSource?.()?.senderName,
|
||||
conversationTitle: message.contextSource?.()?.conversationTitle,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
|
|
@ -687,6 +690,7 @@ export class TextHarnessBridge {
|
|||
key: conversationKey,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
||||
import { initialSessionTitle } from './session-title.mjs';
|
||||
|
||||
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
||||
|
||||
|
|
@ -6,7 +7,7 @@ function workspaceSession(harness, sessionId) {
|
|||
if (typeof harness.workspaceSession === 'function') {
|
||||
return harness.workspaceSession(sessionId);
|
||||
}
|
||||
return Object.freeze({
|
||||
const session = {
|
||||
sessionId,
|
||||
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
||||
models: (...args) => harness.getSessionModels(sessionId, ...args),
|
||||
|
|
@ -16,7 +17,11 @@ function workspaceSession(harness, sessionId) {
|
|||
stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
|
||||
steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
|
||||
ask: (...args) => harness.ask(sessionId, ...args),
|
||||
});
|
||||
};
|
||||
if (typeof harness.renameSession === 'function') {
|
||||
session.renameTitle = (...args) => harness.renameSession(sessionId, ...args);
|
||||
}
|
||||
return Object.freeze(session);
|
||||
}
|
||||
|
||||
async function sessionExists(session, options) {
|
||||
|
|
@ -42,10 +47,21 @@ export async function askInWorkspaceSession({
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced = false,
|
||||
createOptions,
|
||||
existsOptions,
|
||||
askOptions,
|
||||
}) {
|
||||
const initialTitle = contextEnhanced
|
||||
? initialSessionTitle({
|
||||
text,
|
||||
content,
|
||||
files: typeof askOptions === 'object' ? askOptions?.files : undefined,
|
||||
})
|
||||
: null;
|
||||
const renameSignal = createOptions?.signal
|
||||
?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
|
||||
const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
|
||||
while (true) {
|
||||
try {
|
||||
const binding = await withSessionBindingLock(state, key, async () => {
|
||||
|
|
@ -55,6 +71,16 @@ export async function askInWorkspaceSession({
|
|||
sessionId = await createSession(harness, createOptions);
|
||||
if (await state.setSession(key, sessionId) === false) return null;
|
||||
session = workspaceSession(harness, sessionId);
|
||||
if (initialTitle && typeof session.renameTitle === 'function') {
|
||||
try {
|
||||
await session.renameTitle(initialTitle, renameOptions);
|
||||
} catch (error) {
|
||||
if (error?.code === WORKSPACE_SESSION_STALE || renameOptions?.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
console.warn('[dsh-im] unable to set the initial Session title:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { sessionId, session };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1007,11 +1007,14 @@ export class WecomHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'wecom',
|
||||
senderId,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
|
|
@ -1021,6 +1024,7 @@ export class WecomHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -705,11 +705,14 @@ export class WeixinHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'weixin',
|
||||
senderId: sender,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
|
|
@ -717,7 +720,9 @@ export class WeixinHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -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