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

@@ -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": ""
}

View File

@@ -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":"任务完成"}]}

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

View File

@@ -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` 排序,返回顺序与会话列表一致(置顶优先,其次最近更新)。

View File

@@ -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 触发。

View File

@@ -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 测试在旧实现上失败;进入实现阶段。 |

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

View File

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

View File

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