feat: support conversation state events in JavaScript sessions

This commit is contained in:
shiyue
2026-08-27 22:16:33 +08:00
parent e62a545569
commit 97b4a32dda
12 changed files with 458 additions and 11 deletions

View File

@@ -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 = [

View File

@@ -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_childrenidle 会经过稳定窗口复核以抑制等待态登记前的瞬时空闲。',
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 稳定转为 idlewaiting_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);