feat: support conversation state events in JavaScript sessions
This commit is contained in:
@@ -34,6 +34,8 @@ const HIDDEN_CALLABLE_TOOL_NAMES = new Set([
|
||||
'ccweb_script_send_message',
|
||||
'ccweb_script_select_semantic_branch',
|
||||
'ccweb_script_get_last_message',
|
||||
'ccweb_script_get_conversation_status',
|
||||
'ccweb_script_get_child_conversation_ids',
|
||||
]);
|
||||
|
||||
const TOOLS = [
|
||||
|
||||
@@ -8,7 +8,7 @@ const https = require('https');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const PACKAGE_NAME = '@ccweb/session';
|
||||
const PACKAGE_VERSION = '1.0.0';
|
||||
const PACKAGE_VERSION = '1.1.0';
|
||||
const DEFAULT_LOG_TAIL_BYTES = 64 * 1024;
|
||||
const MAX_SCRIPT_SOURCE_BYTES = 1024 * 1024;
|
||||
const DEFAULT_RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -28,6 +28,8 @@ const SCRIPT_SESSION_TOOL_NAMES = Object.freeze([
|
||||
'ccweb_script_send_message',
|
||||
'ccweb_script_select_semantic_branch',
|
||||
'ccweb_script_get_last_message',
|
||||
'ccweb_script_get_conversation_status',
|
||||
'ccweb_script_get_child_conversation_ids',
|
||||
]);
|
||||
|
||||
const SCRIPT_TOOL_DEFINITIONS = [
|
||||
@@ -91,7 +93,7 @@ const SCRIPT_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: 'ccweb_javascript_session_api',
|
||||
description: '返回 @ccweb/session 标准包、Promise 会话函数和 JavaScript 脚本 MCP 工具的完整结构化使用说明。',
|
||||
description: '返回 @ccweb/session 标准包公开函数的完整结构化使用说明。',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
},
|
||||
];
|
||||
@@ -118,6 +120,20 @@ const DEFAULT_URL = process.env.CC_WEB_SCRIPT_MCP_URL || process.env.CC_WEB_MCP_
|
||||
const RUN_ID = process.env.CC_WEB_SCRIPT_RUN_ID || '';
|
||||
const TOKEN = process.env.CC_WEB_SCRIPT_MCP_TOKEN || '';
|
||||
const SOURCE_ID = process.env.CC_WEB_SOURCE_SESSION_ID || '';
|
||||
const CONVERSATION_STATUSES = new Set(['running', 'waiting_for_children', 'idle']);
|
||||
const EVENT_POLL_INTERVAL_MS = 100;
|
||||
const IDLE_EVENT_STABILITY_MS = 250;
|
||||
|
||||
function localError(code, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
function reportAsyncError(error) {
|
||||
setTimeout(() => { throw error; }, 0);
|
||||
}
|
||||
|
||||
function call(tool, args = {}) {
|
||||
const urlText = DEFAULT_URL;
|
||||
@@ -197,6 +213,86 @@ export async function getLastMessage(conversationId) {
|
||||
const result = await call('ccweb_script_get_last_message', { conversationId });
|
||||
return result.message;
|
||||
}
|
||||
export async function getConversationStatus(conversationId) {
|
||||
const result = await call('ccweb_script_get_conversation_status', { conversationId });
|
||||
if (!CONVERSATION_STATUSES.has(result.status)) {
|
||||
throw localError('conversation_status_invalid', 'ccweb 返回了未知的会话状态。', { conversationId, status: result.status });
|
||||
}
|
||||
return result.status;
|
||||
}
|
||||
export async function getChildConversationIds(conversationId) {
|
||||
const result = await call('ccweb_script_get_child_conversation_ids', { conversationId });
|
||||
if (!Array.isArray(result.childConversationIds) || result.childConversationIds.some((item) => typeof item !== 'string')) {
|
||||
throw localError('conversation_children_invalid', 'ccweb 返回了无效的子对话 ID 数组。', { conversationId });
|
||||
}
|
||||
return result.childConversationIds;
|
||||
}
|
||||
export async function onConversationEvent(conversationId, event, listener) {
|
||||
if (event !== 'idle') {
|
||||
throw localError('conversation_event_invalid', '当前只支持监听 idle 事件。', { conversationId, event });
|
||||
}
|
||||
if (typeof listener !== 'function') {
|
||||
throw localError('conversation_listener_invalid', 'listener 必须是函数。', { conversationId, event });
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let timer = null;
|
||||
const initialStatus = await getConversationStatus(conversationId);
|
||||
let lastActiveStatus = initialStatus === 'idle' ? null : initialStatus;
|
||||
let idleCandidateAt = null;
|
||||
|
||||
const unsubscribe = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const schedule = (poll) => {
|
||||
if (!active) return;
|
||||
timer = setTimeout(poll, EVENT_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const status = await getConversationStatus(conversationId);
|
||||
if (!active) return;
|
||||
if (status === 'idle') {
|
||||
if (lastActiveStatus) {
|
||||
const now = Date.now();
|
||||
if (idleCandidateAt === null) idleCandidateAt = now;
|
||||
if (now - idleCandidateAt >= IDLE_EVENT_STABILITY_MS) {
|
||||
const previousStatus = lastActiveStatus;
|
||||
lastActiveStatus = null;
|
||||
idleCandidateAt = null;
|
||||
try {
|
||||
Promise.resolve(listener({
|
||||
conversationId,
|
||||
event: 'idle',
|
||||
previousStatus,
|
||||
status: 'idle',
|
||||
occurredAt: new Date().toISOString(),
|
||||
})).catch(reportAsyncError);
|
||||
} catch (error) {
|
||||
reportAsyncError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastActiveStatus = status;
|
||||
idleCandidateAt = null;
|
||||
}
|
||||
schedule(poll);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
reportAsyncError(error);
|
||||
}
|
||||
};
|
||||
|
||||
schedule(poll);
|
||||
return unsubscribe;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -208,6 +304,20 @@ function packageModuleJsonSource() {
|
||||
return JSON.stringify({ name: PACKAGE_NAME, version: PACKAGE_VERSION, private: true, type: 'module', main: './index.js', exports: './index.js' }, null, 2) + '\n';
|
||||
}
|
||||
|
||||
function writeGeneratedFileIfChanged(filePath, content) {
|
||||
try {
|
||||
if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) return;
|
||||
} catch {}
|
||||
const temp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temp, content);
|
||||
fs.renameSync(temp, filePath);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(temp); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createJavascriptSessionRuntime(deps = {}) {
|
||||
const activeRuns = new Map();
|
||||
const sessionsDir = deps.sessionsDir || process.cwd();
|
||||
@@ -235,8 +345,8 @@ function createJavascriptSessionRuntime(deps = {}) {
|
||||
const packageIndexPath = path.join(scriptsDir, 'node_modules', '@ccweb', 'session', 'index.js');
|
||||
try {
|
||||
if (!fs.existsSync(packageJsonPath)) fs.writeFileSync(packageJsonPath, packageJsonSource(), { flag: 'wx' });
|
||||
if (!fs.existsSync(modulePackageJsonPath)) fs.writeFileSync(modulePackageJsonPath, packageModuleJsonSource(), { flag: 'wx' });
|
||||
if (!fs.existsSync(packageIndexPath)) fs.writeFileSync(packageIndexPath, packageIndexSource(), { flag: 'wx' });
|
||||
writeGeneratedFileIfChanged(modulePackageJsonPath, packageModuleJsonSource());
|
||||
writeGeneratedFileIfChanged(packageIndexPath, packageIndexSource());
|
||||
} catch (error) {
|
||||
return stableError('script_package_failed', `无法注入 ${PACKAGE_NAME}:${error.message}`, { sourceConversationId: source.id });
|
||||
}
|
||||
@@ -570,7 +680,8 @@ function createJavascriptSessionRuntime(deps = {}) {
|
||||
packageName: PACKAGE_NAME,
|
||||
version: PACKAGE_VERSION,
|
||||
moduleFormat: 'ESM',
|
||||
importExample: `import { getCurrentConversationId, createConversation, sendMessage, selectSemanticBranch, getLastMessage } from '${PACKAGE_NAME}';`,
|
||||
importExample: `import { getCurrentConversationId, createConversation, sendMessage, selectSemanticBranch, getLastMessage, getConversationStatus, getChildConversationIds, onConversationEvent } from '${PACKAGE_NAME}';`,
|
||||
statusValues: ['running', 'waiting_for_children', 'idle'],
|
||||
functions: [
|
||||
{
|
||||
name: 'getCurrentConversationId',
|
||||
@@ -607,15 +718,40 @@ function createJavascriptSessionRuntime(deps = {}) {
|
||||
description: '返回指定对话最后一条已完成助手消息的纯文本;不会返回消息对象或中间状态。',
|
||||
errors: ['conversation_not_found', 'last_message_not_found', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||
},
|
||||
{
|
||||
name: 'getConversationStatus',
|
||||
parameters: [{ name: 'conversationId', type: 'string' }],
|
||||
returns: "Promise<'running' | 'waiting_for_children' | 'idle'>",
|
||||
description: '返回会话稳定状态;running 优先于 waiting_for_children,idle 会经过稳定窗口复核以抑制等待态登记前的瞬时空闲。',
|
||||
errors: ['conversation_not_found', 'conversation_status_invalid', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||
},
|
||||
{
|
||||
name: 'getChildConversationIds',
|
||||
parameters: [{ name: 'conversationId', type: 'string' }],
|
||||
returns: 'Promise<string[]>',
|
||||
description: '返回指定对话通过 MCP 创建的直接持久子对话 ID,按会话列表顺序排列,不递归包含孙对话。',
|
||||
errors: ['conversation_not_found', 'conversation_children_read_failed', 'conversation_children_invalid', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||
},
|
||||
{
|
||||
name: 'onConversationEvent',
|
||||
parameters: [{ name: 'conversationId', type: 'string' }, { name: 'event', type: "'idle'" }, { name: 'listener', type: 'function' }],
|
||||
returns: 'Promise<() => void>',
|
||||
description: '监听指定会话从 running 或 waiting_for_children 稳定转为 idle;waiting_for_children 和瞬时 idle 不触发。resolve 为注销函数,注销后不再产生新回调。',
|
||||
listenerPayload: '{ conversationId, event, previousStatus, status, occurredAt }',
|
||||
example: "const unsubscribe = await onConversationEvent(id, 'idle', async (event) => { /* 判断并继续 */ });\\nunsubscribe();",
|
||||
errors: ['conversation_not_found', 'conversation_event_invalid', 'conversation_listener_invalid', 'conversation_status_invalid', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||
},
|
||||
],
|
||||
limitations: [
|
||||
'5 个函数均返回 Promise,脚本可直接使用 async/await。',
|
||||
'8 个函数均可通过 async/await 使用;onConversationEvent resolve 为同步注销函数。',
|
||||
'onConversationEvent 当前只支持 idle;活动监听会保持 Node.js 脚本进程存活,必须在不再监听时调用注销函数。',
|
||||
'idle 事件仅在活动状态稳定结束后触发,waiting_for_children 期间和等待态登记前的瞬时 idle 均不触发。',
|
||||
'函数调用依赖当前脚本运行上下文;上下文失效或 MCP 请求失败时会以 Error reject。',
|
||||
],
|
||||
errors: {
|
||||
shape: 'Error',
|
||||
fields: ['code', 'message', 'details'],
|
||||
commonCodes: ['conversation_not_found', 'conversation_execution_failed', 'last_message_not_found', 'semantic_branch_invalid', 'semantic_judge_failed', 'script_expired', 'script_stopped'],
|
||||
commonCodes: ['conversation_not_found', 'conversation_execution_failed', 'last_message_not_found', 'conversation_event_invalid', 'conversation_listener_invalid', 'conversation_status_invalid', 'conversation_children_read_failed', 'semantic_branch_invalid', 'semantic_judge_failed', 'script_expired', 'script_stopped'],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -686,6 +822,8 @@ function createJavascriptSessionRuntime(deps = {}) {
|
||||
ccweb_script_send_message: 'sendMessage',
|
||||
ccweb_script_select_semantic_branch: 'selectSemanticBranch',
|
||||
ccweb_script_get_last_message: 'getLastMessage',
|
||||
ccweb_script_get_conversation_status: 'getConversationStatus',
|
||||
ccweb_script_get_child_conversation_ids: 'getChildConversationIds',
|
||||
}[tool];
|
||||
if (!method || typeof deps.sessionApi?.[method] !== 'function') return stableError('unknown_script_api', `未知脚本会话能力:${tool}`);
|
||||
return deps.sessionApi[method](args || {}, sourceSessionId);
|
||||
|
||||
Reference in New Issue
Block a user