mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-18 08:43:22 +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
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -10,6 +10,23 @@ This file records the notable changes in each dsh-im release. Its format follows
|
||||||
|
|
||||||
- 非视觉模型收到图片时不再直接报错丢图:宿主以 `MODEL_DOES_NOT_SUPPORT_IMAGES` 拒绝带图片的 prompt 后,自动把同一批图片字节按入站文件管线落盘到 Session 工作区,并以"原文本 + 工具分析指引 + `<dsh_im_files>` 清单"的纯文本 prompt 复用同一 rpcId 重试一次,使非视觉模型仍可通过 run_code/pwsh 等工具识图;视觉模型与文件消息行为不变,其余图片错误仍按原样提示。
|
- 非视觉模型收到图片时不再直接报错丢图:宿主以 `MODEL_DOES_NOT_SUPPORT_IMAGES` 拒绝带图片的 prompt 后,自动把同一批图片字节按入站文件管线落盘到 Session 工作区,并以"原文本 + 工具分析指引 + `<dsh_im_files>` 清单"的纯文本 prompt 复用同一 rpcId 重试一次,使非视觉模型仍可通过 run_code/pwsh 等工具识图;视觉模型与文件消息行为不变,其余图片错误仍按原样提示。
|
||||||
Sending an image to a non-vision model no longer fails outright: when the Host rejects an image-bearing prompt with `MODEL_DOES_NOT_SUPPORT_IMAGES`, the same image bytes are automatically staged into the Session workspace through the inbound-file pipeline and retried once as a text-only prompt (original text plus tool-analysis guidance and the `<dsh_im_files>` manifest) under the same rpcId, so non-vision models can still inspect images via tools such as run_code/pwsh. Vision models and file messages are unchanged, and other image errors keep their existing messages.
|
Sending an image to a non-vision model no longer fails outright: when the Host rejects an image-bearing prompt with `MODEL_DOES_NOT_SUPPORT_IMAGES`, the same image bytes are automatically staged into the Session workspace through the inbound-file pipeline and retried once as a text-only prompt (original text plus tool-analysis guidance and the `<dsh_im_files>` manifest) under the same rpcId, so non-vision models can still inspect images via tools such as run_code/pwsh. Vision models and file messages are unchanged, and other image errors keep their existing messages.
|
||||||
|
## [4.5.0] - 2026-09-01
|
||||||
|
|
||||||
|
### Added / 新增
|
||||||
|
|
||||||
|
- `/workspace` 现在支持使用 `/workspacelist` 中的工作区序号切换,并在执行命令时按最新列表解析;原有绝对路径用法保持不变。
|
||||||
|
`/workspace` now accepts a workspace number from `/workspacelist`, resolved against the latest list when the command runs; the existing absolute-path form remains supported.
|
||||||
|
|
||||||
|
- 上下文增强新增可选的 `conversationTitle` 来源字段;渠道入站事件提供会话标题时可将其写入 `<dsh_im_source>`,无需额外的平台 API 请求。
|
||||||
|
Context enhancement now offers an optional `conversationTitle` source field. When an inbound channel event provides a conversation title, it can be included in `<dsh_im_source>` without an additional platform API request.
|
||||||
|
|
||||||
|
### Fixed / 修复
|
||||||
|
|
||||||
|
- 新建 Harness Session 的标题现在始终基于未经上下文增强的首条用户消息:启用增强时会移除注入块后安全设置标题,未启用增强时继续保留 Harness 的原生自动标题;标题会清理控制字符并按 UTF-8 字节安全截断。
|
||||||
|
New Harness Session titles now consistently reflect the unenhanced first user message. With enhancement enabled, dsh-im safely sets a title without injected context blocks; without enhancement, the Harness-native automatic title is preserved. Titles are sanitized and truncated safely by UTF-8 byte length.
|
||||||
|
|
||||||
|
- 飞书流式回复在同一轮出现 Harness 问题或审批交互时,会先结束当前卡片并在交互完成后创建新卡片,使最终回答显示在交互卡片之后;卡片轮换失败时保持可用的降级投递。
|
||||||
|
When a Feishu streaming turn presents an in-turn Harness question or approval, dsh-im now finalizes the current card and starts a new one after the interaction so the final answer appears below the interaction card, with usable fallback delivery if card rotation fails.
|
||||||
|
|
||||||
## [4.4.0] - 2026-09-01
|
## [4.4.0] - 2026-09-01
|
||||||
|
|
||||||
|
|
@ -554,7 +571,8 @@ This file records the notable changes in each dsh-im release. Its format follows
|
||||||
- 改进 npm 发布包结构,保留 CLI 入口并避免安装脚本拦截。
|
- 改进 npm 发布包结构,保留 CLI 入口并避免安装脚本拦截。
|
||||||
Improved npm package contents to preserve the CLI entry point and avoid install-script blocking.
|
Improved npm package contents to preserve the CLI entry point and avoid install-script blocking.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/xmanrui/dsh-im/compare/v4.4.0...HEAD
|
[Unreleased]: https://github.com/xmanrui/dsh-im/compare/v4.5.0...HEAD
|
||||||
|
[4.5.0]: https://github.com/xmanrui/dsh-im/compare/v4.4.0...v4.5.0
|
||||||
[4.4.0]: https://github.com/xmanrui/dsh-im/compare/v4.3.0...v4.4.0
|
[4.4.0]: https://github.com/xmanrui/dsh-im/compare/v4.3.0...v4.4.0
|
||||||
[4.3.0]: https://github.com/xmanrui/dsh-im/compare/v4.2.1...v4.3.0
|
[4.3.0]: https://github.com/xmanrui/dsh-im/compare/v4.2.1...v4.3.0
|
||||||
[4.2.1]: https://github.com/xmanrui/dsh-im/compare/v4.2.0...v4.2.1
|
[4.2.1]: https://github.com/xmanrui/dsh-im/compare/v4.2.0...v4.2.1
|
||||||
|
|
|
||||||
|
|
@ -571,7 +571,7 @@ var React23 = __toESM(require("react"), 1);
|
||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@xmanrui/dsh-im",
|
name: "@xmanrui/dsh-im",
|
||||||
version: "4.4.0",
|
version: "4.5.0",
|
||||||
description: "\u628A\u4E5D\u79CD IM \u673A\u5668\u4EBA\u548C\u516C\u7F51 AI Office \u63A5\u5165\u672C\u673A DeepSeek Harness\u3002 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
description: "\u628A\u4E5D\u79CD IM \u673A\u5668\u4EBA\u548C\u516C\u7F51 AI Office \u63A5\u5165\u672C\u673A DeepSeek Harness\u3002 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
||||||
keywords: [
|
keywords: [
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
|
|
|
||||||
431
lib/index.js
431
lib/index.js
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "4.4.0",
|
"version": "4.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "4.4.0",
|
"version": "4.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tencent-connect/qqbot-connector": "1.2.0",
|
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "4.4.0",
|
"version": "4.5.0",
|
||||||
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,9 @@ class ModernHarnessApi {
|
||||||
{ request: { requestId: request.rpcId, ...request.payload } },
|
{ request: { requestId: request.rpcId, ...request.payload } },
|
||||||
signal,
|
signal,
|
||||||
)),
|
)),
|
||||||
|
rename: (request, signal) => rpcResult(request, () => this.#invoke(
|
||||||
|
'session', 'rename', { request: request.payload }, signal,
|
||||||
|
)),
|
||||||
cancel: (request, signal) => rpcResult(request, () => this.#invoke(
|
cancel: (request, signal) => rpcResult(request, () => this.#invoke(
|
||||||
'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
|
'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
|
||||||
)),
|
)),
|
||||||
|
|
|
||||||
|
|
@ -996,13 +996,16 @@ export class DingtalkHarnessBridge {
|
||||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||||
|
let contextEnhanced = false;
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
const originalContent = content ?? text;
|
||||||
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||||
channel: 'dingtalk',
|
channel: 'dingtalk',
|
||||||
senderId: sender,
|
senderId: sender,
|
||||||
senderName: message.senderNick,
|
senderName: message.senderNick,
|
||||||
conversationTitle: message.conversationTitle,
|
conversationTitle: message.conversationTitle,
|
||||||
}));
|
}));
|
||||||
|
contextEnhanced = content !== originalContent;
|
||||||
}
|
}
|
||||||
if (typeof this.#api.createAiCard === 'function'
|
if (typeof this.#api.createAiCard === 'function'
|
||||||
&& typeof this.#api.updateAiCard === 'function'
|
&& typeof this.#api.updateAiCard === 'function'
|
||||||
|
|
@ -1021,7 +1024,9 @@ export class DingtalkHarnessBridge {
|
||||||
harness: this.#harness,
|
harness: this.#harness,
|
||||||
state: this.#state,
|
state: this.#state,
|
||||||
key,
|
key,
|
||||||
...(content !== undefined ? { content } : { text }),
|
text,
|
||||||
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: {
|
askOptions: {
|
||||||
|
|
|
||||||
|
|
@ -3293,11 +3293,14 @@ export class FeishuHarnessBridge {
|
||||||
? await promptContentForMessage(message, { signal: this.#signal })
|
? await promptContentForMessage(message, { signal: this.#signal })
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||||
|
let contextEnhanced = false;
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
const originalContent = content ?? text;
|
||||||
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||||
channel: 'feishu',
|
channel: 'feishu',
|
||||||
senderId: senderOpenId(event),
|
senderId: senderOpenId(event),
|
||||||
}));
|
}));
|
||||||
|
contextEnhanced = content !== originalContent;
|
||||||
}
|
}
|
||||||
if (!this.#channel?.stream) {
|
if (!this.#channel?.stream) {
|
||||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||||
|
|
@ -3306,6 +3309,7 @@ export class FeishuHarnessBridge {
|
||||||
key,
|
key,
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||||
|
|
@ -3375,6 +3379,7 @@ export class FeishuHarnessBridge {
|
||||||
key,
|
key,
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions,
|
askOptions,
|
||||||
|
|
@ -3438,6 +3443,7 @@ export class FeishuHarnessBridge {
|
||||||
key,
|
key,
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||||
|
|
|
||||||
|
|
@ -840,12 +840,15 @@ export class QqHarnessBridge {
|
||||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||||
|
let contextEnhanced = false;
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
const originalContent = content ?? text;
|
||||||
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||||
channel: 'qq',
|
channel: 'qq',
|
||||||
senderId: sender,
|
senderId: sender,
|
||||||
senderName: message.kind === 'group' ? message.senderName : undefined,
|
senderName: message.kind === 'group' ? message.senderName : undefined,
|
||||||
}));
|
}));
|
||||||
|
contextEnhanced = content !== originalContent;
|
||||||
}
|
}
|
||||||
// QQ stream_messages can acknowledge a final frame without rendering it in
|
// QQ stream_messages can acknowledge a final frame without rendering it in
|
||||||
// some C2C clients. Standard Markdown delivery is the reliable reply path.
|
// some C2C clients. Standard Markdown delivery is the reliable reply path.
|
||||||
|
|
@ -860,7 +863,9 @@ export class QqHarnessBridge {
|
||||||
harness: this.#harness,
|
harness: this.#harness,
|
||||||
state: this.#state,
|
state: this.#state,
|
||||||
key,
|
key,
|
||||||
...(content !== undefined ? { content } : { text }),
|
text,
|
||||||
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: {
|
askOptions: {
|
||||||
|
|
|
||||||
|
|
@ -1211,6 +1211,11 @@ export function createBotWorkspaceScope(
|
||||||
readHistory(...args) {
|
readHistory(...args) {
|
||||||
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
||||||
},
|
},
|
||||||
|
...(typeof target.renameSession === 'function' ? {
|
||||||
|
renameTitle(...args) {
|
||||||
|
return invokeStartedSessionMutation('renameSession', args, 'title rename');
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
selectModel(...args) {
|
selectModel(...args) {
|
||||||
return invokeCurrentSession('selectSessionModel', args, 'model selection');
|
return invokeCurrentSession('selectSessionModel', args, 'model selection');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -917,6 +917,12 @@ export class HarnessClient {
|
||||||
return created.sessionId;
|
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 = {}) {
|
async executeCommand(sessionId, line, options = {}) {
|
||||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||||
if (typeof line !== 'string' || !line) throw new TypeError('command line 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 })
|
? await promptContentForMessage(message, { signal: this.#signal })
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||||
|
let contextEnhanced = false;
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
const originalContent = content ?? text;
|
||||||
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||||
channel: this.#descriptor.key,
|
channel: this.#descriptor.key,
|
||||||
senderId,
|
senderId,
|
||||||
senderName: message.contextSource?.()?.senderName,
|
senderName: message.contextSource?.()?.senderName,
|
||||||
conversationTitle: message.contextSource?.()?.conversationTitle,
|
conversationTitle: message.contextSource?.()?.conversationTitle,
|
||||||
}));
|
}));
|
||||||
|
contextEnhanced = content !== originalContent;
|
||||||
}
|
}
|
||||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||||
harness: this.#harness,
|
harness: this.#harness,
|
||||||
|
|
@ -687,6 +690,7 @@ export class TextHarnessBridge {
|
||||||
key: conversationKey,
|
key: conversationKey,
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||||
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||||
askOptions: {
|
askOptions: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
||||||
|
import { initialSessionTitle } from './session-title.mjs';
|
||||||
|
|
||||||
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
||||||
|
|
||||||
|
|
@ -6,7 +7,7 @@ function workspaceSession(harness, sessionId) {
|
||||||
if (typeof harness.workspaceSession === 'function') {
|
if (typeof harness.workspaceSession === 'function') {
|
||||||
return harness.workspaceSession(sessionId);
|
return harness.workspaceSession(sessionId);
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
const session = {
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
||||||
models: (...args) => harness.getSessionModels(sessionId, ...args),
|
models: (...args) => harness.getSessionModels(sessionId, ...args),
|
||||||
|
|
@ -16,7 +17,11 @@ function workspaceSession(harness, sessionId) {
|
||||||
stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
|
stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
|
||||||
steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
|
steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
|
||||||
ask: (...args) => harness.ask(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) {
|
async function sessionExists(session, options) {
|
||||||
|
|
@ -42,10 +47,21 @@ export async function askInWorkspaceSession({
|
||||||
key,
|
key,
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
|
contextEnhanced = false,
|
||||||
createOptions,
|
createOptions,
|
||||||
existsOptions,
|
existsOptions,
|
||||||
askOptions,
|
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) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const binding = await withSessionBindingLock(state, key, async () => {
|
const binding = await withSessionBindingLock(state, key, async () => {
|
||||||
|
|
@ -55,6 +71,16 @@ export async function askInWorkspaceSession({
|
||||||
sessionId = await createSession(harness, createOptions);
|
sessionId = await createSession(harness, createOptions);
|
||||||
if (await state.setSession(key, sessionId) === false) return null;
|
if (await state.setSession(key, sessionId) === false) return null;
|
||||||
session = workspaceSession(harness, sessionId);
|
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 };
|
return { sessionId, session };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1007,11 +1007,14 @@ export class WecomHarnessBridge {
|
||||||
? await promptContentForMessage(message, { signal: this.#signal })
|
? await promptContentForMessage(message, { signal: this.#signal })
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||||
|
let contextEnhanced = false;
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
const originalContent = content ?? text;
|
||||||
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||||
channel: 'wecom',
|
channel: 'wecom',
|
||||||
senderId,
|
senderId,
|
||||||
}));
|
}));
|
||||||
|
contextEnhanced = content !== originalContent;
|
||||||
}
|
}
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
promptRecorded = true;
|
promptRecorded = true;
|
||||||
|
|
@ -1021,6 +1024,7 @@ export class WecomHarnessBridge {
|
||||||
key,
|
key,
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: {
|
askOptions: {
|
||||||
|
|
|
||||||
|
|
@ -705,11 +705,14 @@ export class WeixinHarnessBridge {
|
||||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||||
: undefined;
|
: undefined;
|
||||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||||
|
let contextEnhanced = false;
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
const originalContent = content ?? text;
|
||||||
|
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||||
channel: 'weixin',
|
channel: 'weixin',
|
||||||
senderId: sender,
|
senderId: sender,
|
||||||
}));
|
}));
|
||||||
|
contextEnhanced = content !== originalContent;
|
||||||
}
|
}
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
promptRecorded = true;
|
promptRecorded = true;
|
||||||
|
|
@ -717,7 +720,9 @@ export class WeixinHarnessBridge {
|
||||||
harness: this.#harness,
|
harness: this.#harness,
|
||||||
state: this.#state,
|
state: this.#state,
|
||||||
key,
|
key,
|
||||||
...(content !== undefined ? { content } : { text }),
|
text,
|
||||||
|
content,
|
||||||
|
contextEnhanced,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: {
|
askOptions: {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@ function fixture(channel, { contextEnhancement, onAsk } = {}) {
|
||||||
const harness = {
|
const harness = {
|
||||||
ensureRunning: async () => { calls.push(['ensureRunning']); },
|
ensureRunning: async () => { calls.push(['ensureRunning']); },
|
||||||
createSession: async () => { calls.push(['createSession']); return 'session-existing'; },
|
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; },
|
sessionExists: async (id) => { calls.push(['sessionExists', id]); return true; },
|
||||||
hasActiveTurn: async () => false,
|
hasActiveTurn: async () => false,
|
||||||
isSessionRunning: 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 }));
|
await baseline.bridge.accept(baseline.event(1, undefined, { kind, media, poisonName: true }));
|
||||||
assert.equal(baseline.prompts.length, 1);
|
assert.equal(baseline.prompts.length, 1);
|
||||||
assert.equal(baseline.sourceReads, 0);
|
assert.equal(baseline.sourceReads, 0);
|
||||||
|
assert.equal(baseline.calls.some(([operation]) => operation === 'renameSession'), false);
|
||||||
for (const contextEnhancement of variants) {
|
for (const contextEnhancement of variants) {
|
||||||
const current = fixture(channel, { contextEnhancement });
|
const current = fixture(channel, { contextEnhancement });
|
||||||
await current.bridge.accept(current.event(1, undefined, { kind, media, poisonName: true }));
|
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.deepEqual(sourceOf(current.prompts[0]), expected);
|
||||||
assert.ok(current.prompts[0].endsWith('\n\nhello'));
|
assert.ok(current.prompts[0].endsWith('\n\nhello'));
|
||||||
assert.doesNotMatch(current.prompts[0], /source_guidance|private-token|private-secret/);
|
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.
|
// Explicit null represents a provider event that does not include a nickname.
|
||||||
await current.bridge.accept(current.event(2, 'missing name', { kind, name: null }));
|
await current.bridge.accept(current.event(2, 'missing name', { kind, name: null }));
|
||||||
assert.equal(Object.hasOwn(sourceOf(current.prompts[1]), 'senderName'), false);
|
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.ok(enhanced.endsWith(`\n\n${original}`));
|
||||||
}
|
}
|
||||||
assert.deepEqual(enabled.calls.filter(([op]) => op === 'file'), plain.calls.filter(([op]) => op === 'file'));
|
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'));
|
await current.bridge.accept(current.event(1, 'hello'));
|
||||||
if (config.direct.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
|
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 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 calls = [];
|
||||||
const apiProxy = {};
|
const apiProxy = {};
|
||||||
const methods = ['host.describe', 'workspace.list', 'workspace.create', 'session.list',
|
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'];
|
'session.models', 'session.selectModel', 'llm.models'];
|
||||||
for (const method of methods) {
|
for (const method of methods) {
|
||||||
const [domain, action] = method.split('.');
|
const [domain, action] = method.split('.');
|
||||||
|
|
@ -191,6 +191,29 @@ test('explicit baseUrl still selects the existing HTTP transport', async () => {
|
||||||
assert.equal(requests, 1);
|
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 () => {
|
test('history reading uses only the existing read RPC in both Host connection modes', async () => {
|
||||||
const page = { events: [], hasMore: false };
|
const page = { events: [], hasMore: false };
|
||||||
for (const mode of ['local', 'http']) {
|
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/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/cancel') return { accepted: true };
|
||||||
if (endpoint === 'session/selectModel') return { selected: request.args.request };
|
if (endpoint === 'session/selectModel') return { selected: request.args.request };
|
||||||
throw new Error(`unexpected invoke ${endpoint}`);
|
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');
|
const promptCall = calls.find((call) => call.namespace === 'session' && call.method === 'prompt');
|
||||||
assert.equal(promptCall.args.request.requestId, 'prompt-correlation');
|
assert.equal(promptCall.args.request.requestId, 'prompt-correlation');
|
||||||
assert.equal(Object.hasOwn(promptCall.args.request, 'rpcId'), false);
|
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 () => {
|
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 state = memoryState({ conversation: 'session-old' });
|
||||||
const asked = [];
|
const asked = [];
|
||||||
let createCalls = 0;
|
let createCalls = 0;
|
||||||
|
let renameCalls = 0;
|
||||||
const scope = createBotWorkspaceScope({
|
const scope = createBotWorkspaceScope({
|
||||||
async adoptWorkspaceSession(sessionId) {
|
async adoptWorkspaceSession(sessionId) {
|
||||||
return { sessionId, workspace: alternateWorkspace };
|
return { sessionId, workspace: alternateWorkspace };
|
||||||
|
|
@ -184,6 +185,7 @@ test('the next message continues the bound Session without creating a new one',
|
||||||
createCalls += 1;
|
createCalls += 1;
|
||||||
return 'session-created-unexpectedly';
|
return 'session-created-unexpectedly';
|
||||||
},
|
},
|
||||||
|
async renameSession() { renameCalls += 1; },
|
||||||
async ask(sessionId, text) {
|
async ask(sessionId, text) {
|
||||||
asked.push({ sessionId, text });
|
asked.push({ sessionId, text });
|
||||||
return 'continued answer';
|
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(reply, { sessionId: 'session-target', answer: 'continued answer' });
|
||||||
assert.deepEqual(asked, [{ sessionId: 'session-target', text: 'continue here' }]);
|
assert.deepEqual(asked, [{ sessionId: 'session-target', text: 'continue here' }]);
|
||||||
assert.equal(createCalls, 0);
|
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 state = memoryState();
|
||||||
const askStarted = deferred();
|
const askStarted = deferred();
|
||||||
const releaseAsk = deferred();
|
const releaseAsk = deferred();
|
||||||
|
|
@ -226,6 +267,9 @@ test('a first prompt and model switch share one binding without holding the lock
|
||||||
workspaceSession(sessionId) {
|
workspaceSession(sessionId) {
|
||||||
return {
|
return {
|
||||||
async sessionExists() { return true; },
|
async sessionExists() { return true; },
|
||||||
|
async renameTitle(title) {
|
||||||
|
calls.push(['renameTitle', sessionId, title]);
|
||||||
|
},
|
||||||
async ask(text) {
|
async ask(text) {
|
||||||
calls.push(['ask', sessionId, text]);
|
calls.push(['ask', sessionId, text]);
|
||||||
askStarted.resolve();
|
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