fix: preserve session titles without context enhancement

This commit is contained in:
xmanrui 2026-09-01 22:30:23 +08:00
parent e8c9817598
commit d044f1b0aa
17 changed files with 527 additions and 223 deletions

View file

@ -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: {

View file

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

View file

@ -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: {

View file

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

View file

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

View 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;

View file

@ -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: {

View file

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

View file

@ -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: {

View file

@ -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: {