feat: support conversation state events in JavaScript sessions
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"runId": "86e8f20f-3e55-4ca6-9def-57b79725b4fc",
|
||||
"sourceConversationId": "c01c2e84-5d52-4b0b-bab0-e10daf6c65c9",
|
||||
"scriptsDir": "/home/cc-web/.ccweb/scripts",
|
||||
"name": "conversation-idle-listener-e2e-20260827.js",
|
||||
"scriptPath": "/home/cc-web/.ccweb/scripts/conversation-idle-listener-e2e-20260827.js",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2026-08-27T14:04:55.516Z",
|
||||
"finishedAt": "2026-08-27T14:05:46.085Z",
|
||||
"durationMs": 50569,
|
||||
"exitCode": 0,
|
||||
"signal": null,
|
||||
"terminationReason": null,
|
||||
"stopRequested": false,
|
||||
"tokenRevoked": true,
|
||||
"pid": 576697,
|
||||
"stderrPreview": ""
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"sourceConversationId":"c01c2e84-5d52-4b0b-bab0-e10daf6c65c9","conversationId":"43c081dd-2c8d-431c-b75d-3e40dc04806e","initialStatus":"idle","statusDuringFirstTurn":"running","firstMessage":"未完成","continued":true,"continuationMessage":"任务完成","finalStatus":"idle","finalMessage":"任务完成","childIncluded":true,"idleEventCount":2,"events":[{"conversationId":"43c081dd-2c8d-431c-b75d-3e40dc04806e","event":"idle","previousStatus":"running","status":"idle","occurredAt":"2026-08-27T14:05:31.297Z","lastMessage":"未完成"},{"conversationId":"43c081dd-2c8d-431c-b75d-3e40dc04806e","event":"idle","previousStatus":"running","status":"idle","occurredAt":"2026-08-27T14:05:43.829Z","lastMessage":"任务完成"}]}
|
||||
88
.ccweb/scripts/conversation-idle-listener-e2e-20260827.js
Normal file
88
.ccweb/scripts/conversation-idle-listener-e2e-20260827.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
getCurrentConversationId,
|
||||
createConversation,
|
||||
sendMessage,
|
||||
getLastMessage,
|
||||
getConversationStatus,
|
||||
getChildConversationIds,
|
||||
onConversationEvent,
|
||||
} from '@ccweb/session';
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const sourceConversationId = await getCurrentConversationId();
|
||||
const conversationId = await createConversation(
|
||||
'这是会话事件测试,请只回复:测试对话已准备'
|
||||
);
|
||||
|
||||
const initialStatus = await getConversationStatus(conversationId);
|
||||
const childrenBefore = await getChildConversationIds(sourceConversationId);
|
||||
if (!childrenBefore.includes(conversationId)) {
|
||||
throw Object.assign(new Error('新对话未出现在来源对话的直接子对话 ID 中'), {
|
||||
code: 'child_conversation_missing',
|
||||
});
|
||||
}
|
||||
|
||||
const events = [];
|
||||
let continued = false;
|
||||
let continuationMessage = '';
|
||||
let unsubscribe = () => {};
|
||||
let finish;
|
||||
let fail;
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
finish = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
fail(Object.assign(new Error('等待 idle 自动继续闭环超时'), {
|
||||
code: 'idle_listener_timeout',
|
||||
}));
|
||||
}, 180000);
|
||||
|
||||
unsubscribe = await onConversationEvent(conversationId, 'idle', async (event) => {
|
||||
const lastMessage = await getLastMessage(conversationId);
|
||||
events.push({ ...event, lastMessage });
|
||||
|
||||
if (!continued && lastMessage.includes('未完成')) {
|
||||
continued = true;
|
||||
continuationMessage = await sendMessage(
|
||||
conversationId,
|
||||
'请继续完成任务,并且只回复:任务完成'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (continued && lastMessage.includes('任务完成')) {
|
||||
unsubscribe();
|
||||
clearTimeout(timeout);
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
const firstTurnPromise = sendMessage(
|
||||
conversationId,
|
||||
'开始执行第一阶段,并且只回复:未完成'
|
||||
);
|
||||
await delay(150);
|
||||
const statusDuringFirstTurn = await getConversationStatus(conversationId);
|
||||
const firstMessage = await firstTurnPromise;
|
||||
await completed;
|
||||
|
||||
const finalStatus = await getConversationStatus(conversationId);
|
||||
const finalMessage = await getLastMessage(conversationId);
|
||||
const childrenAfter = await getChildConversationIds(sourceConversationId);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
sourceConversationId,
|
||||
conversationId,
|
||||
initialStatus,
|
||||
statusDuringFirstTurn,
|
||||
firstMessage,
|
||||
continued,
|
||||
continuationMessage,
|
||||
finalStatus,
|
||||
finalMessage,
|
||||
childIncluded: childrenAfter.includes(conversationId),
|
||||
idleEventCount: events.length,
|
||||
events,
|
||||
}));
|
||||
@@ -0,0 +1,25 @@
|
||||
# Findings
|
||||
|
||||
- Git `main` 起点干净并与 `origin/main` 同步。
|
||||
- codebase-memory 项目 `home-cc-web` 索引状态为 `ready`。
|
||||
- 会话运行状态的统一判定入口是 `server.js` 中的 `isSessionRunning`。
|
||||
- 标准包源码由 `lib/javascript-session-runtime.js` 的 `packageIndexSource` 动态注入。
|
||||
- 内部脚本能力通过 `handleSessionCall` 映射到 `server.js` 注入的 `sessionApi`。
|
||||
- 会话列表刷新入口是 `broadcastSessionList`,但 idle 边沿不能仅依赖 UI 广播,需要服务端可等待的状态协议或包内封装。
|
||||
- `isSessionRunning` 由多个活动 Map/goal 状态综合推导;查询 API 必须直接复用它,不能另造状态字段。
|
||||
- 当前注入包的 HTTP `call()` 是短请求模型;最小可靠实现是新增服务端状态查询,监听和边沿判定由 `@ccweb/session` 内部封装,调用脚本不需要自行轮询。
|
||||
- `onConversationEvent` 适合设计为异步注册:`const unsubscribe = await onConversationEvent(...)`,注册阶段先验证会话并取得基线状态;注销函数同步停止计时器。
|
||||
- `scriptsDirFor(..., create=true)` 目前只在包入口不存在时写入;若不调整,已有 `.ccweb/scripts` 无法获得新增导出,因此生成包入口应在内容变化时原子刷新。
|
||||
- 现有运行时单测包含真实注入包 + 本地 HTTP MCP 的端到端骨架,可扩展为状态序列与注销验证。
|
||||
- “等待子对话回复”已有统一来源:`crossConversationWaitState(sessionId).waitingOnChildren`,其范围包括 `waiting`、`ready`、`delivering`、`failed` 的未处理跨对话回复。
|
||||
- 状态优先级采用 `running` > `waiting_for_children` > `idle`,避免会话自身仍在执行时被较弱等待态覆盖。
|
||||
- 持久子对话关系保存在 session meta 的 `createdFromKind='mcp'` 与 `createdFromSourceSessionId`;`listConversationSummaries(scope='children')` 已验证该筛选语义。
|
||||
- 新增 `getChildConversationIds` 只返回直接子对话,按现有会话列表排序后提取 ID;不递归包含孙对话。
|
||||
- 协议定型:公开 `onConversationEvent` 使用异步注册并返回同步注销函数;包内每 100ms 串行查询状态,不产生重叠请求,调用脚本无需自行轮询。
|
||||
- idle 事件 payload 为 `{ conversationId, event, previousStatus, status, occurredAt }`;只允许事件名 `idle`。
|
||||
- 注册时先取得基线:初始为 idle 不触发;后续任一活动态(running / waiting_for_children)转 idle 才触发。
|
||||
- 注销会清理定时器;已在途的状态请求返回后也会先检查 active 标记,因此不会产生注销后的新回调。
|
||||
- 监听器同步抛错或返回 rejected Promise 时不静默吞掉,按脚本未捕获异常处理;状态查询故障也停止监听并使脚本异常退出。
|
||||
- 动态包版本提升到 1.1.0,并在创建/写入/运行脚本时原子刷新生成的包入口,确保已有脚本目录获得新导出。
|
||||
- 用户明确要求抑制等待态登记延迟产生的临时 idle:服务端 idle 查询先等待 500ms 后复核,包内事件再要求 idle 连续稳定 250ms;任一时刻观察到 `waiting_for_children` 都取消 idle 候选。
|
||||
- 子对话元数据可直接使用 `compareSessionsForList` 排序,返回顺序与会话列表一致(置顶优先,其次最近更新)。
|
||||
@@ -0,0 +1,20 @@
|
||||
# Progress
|
||||
|
||||
- 2026-08-27:开始新增 `getConversationStatus` 与 `onConversationEvent`。
|
||||
- 已确定公开状态值沿用 ccweb 现有 `running` / `idle`。
|
||||
- 已确认 codebase-memory 索引可用,并定位状态判定、标准包注入和内部调用映射入口。
|
||||
- 计划审查通过;已补齐注销、边沿、manifest、重启前检查与“未完成自动继续”验收点。
|
||||
- 用户追加等待子对话状态与直接子对话 ID 查询;公开函数总数调整为 8。
|
||||
- 已定型公开 API、三态优先级、idle 边沿 payload、注销语义和已有注入包升级策略。
|
||||
- 已补充失败测试:8 函数 manifest、三态值、状态序列 idle→running→idle、注销后停止查询、直接子对话 ID、未知事件错误码。
|
||||
- 红灯结果符合预期:旧实现 manifest 仍为 5 个函数。
|
||||
- 已把 `running → 临时 idle → waiting_for_children → 稳定 idle` 固化为监听回归序列,只有最终稳定 idle 可回调。
|
||||
- 已实现服务端三态查询(idle 500ms 复核)与直接子对话 ID 查询,并接入隐藏脚本 MCP 授权链路。
|
||||
- 已实现 `@ccweb/session` 1.1.0 的 `getConversationStatus`、`getChildConversationIds`、`onConversationEvent`,包内 idle 稳定 250ms 并支持注销。
|
||||
- 已让生成的 `node_modules/@ccweb/session` 在已有脚本目录中按内容原子刷新。
|
||||
- 首轮语法检查与运行时单测通过。
|
||||
- 完整验证通过:`server.js`、运行时、MCP server、单测脚本语法检查,`javascript-session-runtime-unit` 与 `scripts/regression.js` 均成功。
|
||||
- `git diff --check` 通过;manifest 实测为 1.1.0、8 个函数、三态枚举完整。
|
||||
- 已按重启前规则确认仅当前对话运行后完成服务重启;重启后 manifest 与真实脚本均验证通过。
|
||||
- 真实闭环通过:新建子对话 → 监听 idle → 首次“未完成” → 回调内 `sendMessage` → 第二次“任务完成” → 注销;直接子对话 ID 查询和运行中状态查询同时通过。
|
||||
- 确认 `waiting_for_children` 期间及登记延迟造成的临时 idle 均不会触发监听;仅稳定活动态→idle 触发。
|
||||
@@ -0,0 +1,47 @@
|
||||
# JavaScript 会话状态与 idle 事件
|
||||
|
||||
## 目标
|
||||
|
||||
为 `@ccweb/session` 增加会话状态查询、直接子对话 ID 查询,以及指定会话从活动状态转为 `idle` 时的事件监听与注销能力;脚本不需要自行编写轮询。
|
||||
|
||||
## 已确认语义
|
||||
|
||||
- `getConversationStatus(conversationId)` 返回 `Promise<'running' | 'waiting_for_children' | 'idle'>`,优先级为 `running` > `waiting_for_children` > `idle`。
|
||||
- `getChildConversationIds(conversationId)` 返回该对话通过 MCP 创建的直接持久子对话 ID,不递归返回孙对话。
|
||||
- `onConversationEvent(conversationId, 'idle', listener)` 监听 `running/waiting_for_children → idle` 边沿,不因注册时已是 `idle` 而立即触发。
|
||||
- `onConversationEvent` 返回注销函数;注销后不再产生新回调。
|
||||
- 第一版只公开 `idle` 事件,但协议应便于后续扩展。
|
||||
- 回调错误不应破坏底层监听;由脚本自己的未捕获异常规则处理。
|
||||
|
||||
## 阶段
|
||||
|
||||
1. [complete] 梳理现有会话状态与脚本包调用链
|
||||
2. [complete] 确定状态查询、idle 等待、取消注销和 manifest 的协议语义
|
||||
3. [complete] 补充失败测试覆盖状态查询和 idle 监听
|
||||
4. [complete] 实现服务端状态查询与事件等待能力
|
||||
5. [complete] 实现 @ccweb/session 监听、注销和状态 API
|
||||
6. [complete] 更新 manifest 并运行单元与回归测试
|
||||
7. [complete] 检查运行对话后重启并验证 idle 后未完成则继续发送的闭环
|
||||
8. [complete] 清理临时计划并交付使用示例
|
||||
|
||||
## 风险
|
||||
|
||||
- 长时间监听必须能让脚本进程保持运行,又不能在注销后遗留服务端等待器。
|
||||
- idle 必须是活动状态到 idle 的边沿事件,不能把“注册时已经 idle”误判为新完成。
|
||||
- 当前来源对话允许 steer;事件监听必须与现有状态判定保持一致。
|
||||
- 重启前必须确认除当前对话外不存在其他 `running` 会话;否则不重启并记录阻塞。
|
||||
|
||||
## 协议验收清单
|
||||
|
||||
- 状态查询:按 conversationId 返回 `running`、`waiting_for_children` 或 `idle`。
|
||||
- 子对话查询:按 conversationId 返回直接子对话 ID 数组,不递归。
|
||||
- 事件等待:只支持 `idle`,且只响应 `running/waiting_for_children → idle`。
|
||||
- 注销取消:注销后停止包内监听,不再回调,也不遗留活动资源。
|
||||
- manifest:三个新函数的参数、返回值、状态值、触发/注销语义和错误码与实现一致,总计 8 个函数。
|
||||
- 场景闭环:idle 回调中判断任务未完成后调用 `sendMessage` 继续,随后再次进入 idle。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---:|---|
|
||||
| 运行时单测 `5 !== 8` | 1 | 预期的红灯,证明新增 manifest/API 测试在旧实现上失败;进入实现阶段。 |
|
||||
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -33,6 +33,7 @@ async function main() {
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const sourceId = '11111111-1111-4111-8111-111111111111';
|
||||
const childId = '33333333-3333-4333-8333-333333333333';
|
||||
const sessions = new Map([[sourceId, {
|
||||
id: sourceId,
|
||||
cwd,
|
||||
@@ -40,6 +41,18 @@ async function main() {
|
||||
}]]);
|
||||
fs.writeFileSync(path.join(sessionsDir, `${sourceId}.json`), JSON.stringify(sessions.get(sourceId)));
|
||||
const notifications = [];
|
||||
let statusCallCount = 0;
|
||||
const statusSequence = [
|
||||
'idle',
|
||||
'idle',
|
||||
'running',
|
||||
'idle',
|
||||
'waiting_for_children',
|
||||
'idle',
|
||||
'idle',
|
||||
'idle',
|
||||
'idle',
|
||||
];
|
||||
|
||||
const mcpServer = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
@@ -56,6 +69,17 @@ async function main() {
|
||||
case 'ccweb_script_get_last_message':
|
||||
response = { ok: true, conversationId: payload.args.conversationId, message: '服务端最后消息' };
|
||||
break;
|
||||
case 'ccweb_script_get_conversation_status':
|
||||
statusCallCount += 1;
|
||||
response = {
|
||||
ok: true,
|
||||
conversationId: payload.args.conversationId,
|
||||
status: statusSequence[Math.min(statusCallCount - 1, statusSequence.length - 1)],
|
||||
};
|
||||
break;
|
||||
case 'ccweb_script_get_child_conversation_ids':
|
||||
response = { ok: true, conversationId: payload.args.conversationId, childConversationIds: [childId] };
|
||||
break;
|
||||
default:
|
||||
response = { ok: false, code: 'unexpected_test_tool', message: payload.tool };
|
||||
}
|
||||
@@ -77,24 +101,41 @@ async function main() {
|
||||
assert.equal(runtime.getApiManifest().ok, true);
|
||||
assert.equal(runtime.getApiManifest().packageName, '@ccweb/session');
|
||||
const manifest = runtime.getApiManifest();
|
||||
assert.equal(manifest.functions.length, 5);
|
||||
assert.equal(manifest.functions.length, 8);
|
||||
assert.equal(manifest.functions.every((item) => item.description && Array.isArray(item.errors)), true);
|
||||
assert.deepEqual(manifest.statusValues, ['running', 'waiting_for_children', 'idle']);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'scriptTools'), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'scriptToolExamples'), false);
|
||||
assert.match(packageJsonSource(), /"type": "module"/);
|
||||
assert.match(packageIndexSource(), /export async function sendMessage/);
|
||||
assert.match(packageIndexSource(), /export async function getConversationStatus/);
|
||||
assert.match(packageIndexSource(), /export async function getChildConversationIds/);
|
||||
assert.match(packageIndexSource(), /export async function onConversationEvent/);
|
||||
|
||||
assert.equal(runtime.createScript({ name: '../escape.js' }, sourceId).code, 'invalid_script_name');
|
||||
assert.equal(runtime.createScript({ name: 'workflow.txt' }, sourceId).code, 'invalid_script_name');
|
||||
assert.equal(runtime.createScript({ name: 'workflow.js' }, sourceId).ok, true);
|
||||
assert.equal(runtime.createScript({ name: 'workflow.js' }, sourceId).code, 'script_exists');
|
||||
const injectedPackageIndex = path.join(cwd, '.ccweb', 'scripts', 'node_modules', '@ccweb', 'session', 'index.js');
|
||||
fs.writeFileSync(injectedPackageIndex, '// stale generated package\n');
|
||||
|
||||
const workflow = [
|
||||
"import { getCurrentConversationId, getLastMessage } from '@ccweb/session';",
|
||||
'await new Promise((resolve) => setTimeout(resolve, 400));',
|
||||
'console.log(JSON.stringify({ current: await getCurrentConversationId(), last: await getLastMessage(await getCurrentConversationId()) }));',
|
||||
"import { getCurrentConversationId, getLastMessage, getConversationStatus, getChildConversationIds, onConversationEvent } from '@ccweb/session';",
|
||||
'const current = await getCurrentConversationId();',
|
||||
'const initialStatus = await getConversationStatus(current);',
|
||||
'const children = await getChildConversationIds(current);',
|
||||
'let unsubscribe = () => {};',
|
||||
'let resolveIdle;',
|
||||
'const idlePromise = new Promise((resolve) => { resolveIdle = resolve; });',
|
||||
"unsubscribe = await onConversationEvent(current, 'idle', (event) => { unsubscribe(); resolveIdle(event); });",
|
||||
'const idleEvent = await idlePromise;',
|
||||
'await new Promise((resolve) => setTimeout(resolve, 250));',
|
||||
"let invalidEventCode = '';",
|
||||
"try { await onConversationEvent(current, 'running', () => {}); } catch (error) { invalidEventCode = error.code; }",
|
||||
'console.log(JSON.stringify({ current, last: await getLastMessage(current), initialStatus, children, idleEvent, invalidEventCode }));',
|
||||
].join('\n');
|
||||
assert.equal(runtime.writeScript({ name: 'workflow.js', content: workflow }, sourceId).ok, true);
|
||||
assert.match(fs.readFileSync(injectedPackageIndex, 'utf8'), /export async function onConversationEvent/);
|
||||
assert.equal(runtime.writeScript({ name: 'too-large.js', content: 'x'.repeat(1024 * 1024 + 1) }, sourceId).code, 'script_content_too_large');
|
||||
|
||||
const started = runtime.runScript({ name: 'workflow.js' }, sourceId);
|
||||
@@ -105,6 +146,13 @@ async function main() {
|
||||
assert.equal(succeeded.exitCode, 0);
|
||||
assert.match(succeeded.stdout, new RegExp(sourceId));
|
||||
assert.match(succeeded.stdout, /服务端最后消息/);
|
||||
const workflowResult = JSON.parse(succeeded.stdout.trim());
|
||||
assert.equal(workflowResult.initialStatus, 'idle');
|
||||
assert.deepEqual(workflowResult.children, [childId]);
|
||||
assert.equal(workflowResult.idleEvent.previousStatus, 'waiting_for_children');
|
||||
assert.equal(workflowResult.idleEvent.status, 'idle');
|
||||
assert.equal(workflowResult.invalidEventCode, 'conversation_event_invalid');
|
||||
assert.equal(statusCallCount, statusSequence.length, '瞬时 idle 不应触发,注销后不应继续查询状态');
|
||||
assert.equal(succeeded.stderr, '');
|
||||
assert.equal(notifications.length, 0);
|
||||
|
||||
|
||||
60
server.js
60
server.js
@@ -7540,6 +7540,62 @@ function scriptGetLastMessageApi(args = {}) {
|
||||
return Promise.resolve({ ok: true, conversationId, message });
|
||||
}
|
||||
|
||||
const JAVASCRIPT_CONVERSATION_IDLE_STABILITY_MS = 500;
|
||||
|
||||
function javascriptConversationStatusValue(conversationId) {
|
||||
reconcilePendingCrossConversationReplies();
|
||||
if (isSessionRunning(conversationId)) return 'running';
|
||||
if (crossConversationWaitState(conversationId).waitingOnChildren) return 'waiting_for_children';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
async function scriptGetConversationStatusApi(args = {}) {
|
||||
const conversationId = sanitizeId(args.conversationId || '');
|
||||
if (!conversationId) return scriptApiError('conversation_not_found', 'conversationId 无效。');
|
||||
if (!loadSession(conversationId)) {
|
||||
return scriptApiError('conversation_not_found', '目标对话不存在。', { conversationId });
|
||||
}
|
||||
|
||||
let status = javascriptConversationStatusValue(conversationId);
|
||||
if (status === 'idle') {
|
||||
// 等待态登记与运行态清理之间可能短暂出现 idle,稳定窗口后再确认。
|
||||
await new Promise((resolve) => setTimeout(resolve, JAVASCRIPT_CONVERSATION_IDLE_STABILITY_MS));
|
||||
if (!loadSession(conversationId)) {
|
||||
return scriptApiError('conversation_not_found', '目标对话不存在。', { conversationId });
|
||||
}
|
||||
status = javascriptConversationStatusValue(conversationId);
|
||||
}
|
||||
|
||||
return { ok: true, conversationId, status };
|
||||
}
|
||||
|
||||
function scriptGetChildConversationIdsApi(args = {}) {
|
||||
const conversationId = sanitizeId(args.conversationId || '');
|
||||
if (!conversationId) return scriptApiError('conversation_not_found', 'conversationId 无效。');
|
||||
if (!loadSession(conversationId)) {
|
||||
return scriptApiError('conversation_not_found', '目标对话不存在。', { conversationId });
|
||||
}
|
||||
|
||||
try {
|
||||
const children = fs.readdirSync(SESSIONS_DIR)
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.map((name) => loadSessionMetaFromFile(path.join(SESSIONS_DIR, name)))
|
||||
.filter((meta) => (
|
||||
meta
|
||||
&& meta.createdFromKind === 'mcp'
|
||||
&& meta.createdFromSourceSessionId === conversationId
|
||||
))
|
||||
.sort(compareSessionsForList);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
conversationId,
|
||||
childConversationIds: children.map((meta) => meta.id),
|
||||
});
|
||||
} catch (error) {
|
||||
return scriptApiError('conversation_children_read_failed', `读取子对话失败:${error.message}`, { conversationId });
|
||||
}
|
||||
}
|
||||
|
||||
function validateSemanticCandidates(rawSemantics) {
|
||||
if (!Array.isArray(rawSemantics)) return { ok: false, error: scriptApiError('semantic_branch_invalid', '语义数组必须是数组。') };
|
||||
const values = rawSemantics.map((item) => (typeof item === 'string' ? item : null));
|
||||
@@ -7648,6 +7704,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount, script
|
||||
case 'ccweb_script_send_message':
|
||||
case 'ccweb_script_select_semantic_branch':
|
||||
case 'ccweb_script_get_last_message':
|
||||
case 'ccweb_script_get_conversation_status':
|
||||
case 'ccweb_script_get_child_conversation_ids':
|
||||
if (!scriptContext.authorized) return mcpToolError('script_authorization_required', '该能力只能由已授权的 JavaScript 脚本调用。');
|
||||
return javascriptSessionRuntime?.handleSessionCall(tool, args, sourceSessionId)
|
||||
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||
@@ -7931,6 +7989,8 @@ function initializeJavascriptSessionRuntime() {
|
||||
sendMessage: scriptSendMessageApi,
|
||||
selectSemanticBranch: scriptSelectSemanticBranchApi,
|
||||
getLastMessage: scriptGetLastMessageApi,
|
||||
getConversationStatus: scriptGetConversationStatusApi,
|
||||
getChildConversationIds: scriptGetChildConversationIdsApi,
|
||||
},
|
||||
});
|
||||
javascriptSessionRuntime.recover();
|
||||
|
||||
Reference in New Issue
Block a user