diff --git a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/run.json b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/run.json new file mode 100644 index 0000000..da973df --- /dev/null +++ b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/run.json @@ -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": "" +} diff --git a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stderr.log b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stdout.log b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stdout.log new file mode 100644 index 0000000..cf74cd8 --- /dev/null +++ b/.ccweb/scripts/.runs/86e8f20f-3e55-4ca6-9def-57b79725b4fc/stdout.log @@ -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":"任务完成"}]} diff --git a/.ccweb/scripts/conversation-idle-listener-e2e-20260827.js b/.ccweb/scripts/conversation-idle-listener-e2e-20260827.js new file mode 100644 index 0000000..dffce97 --- /dev/null +++ b/.ccweb/scripts/conversation-idle-listener-e2e-20260827.js @@ -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, +})); diff --git a/.planning/2026-08-27-javascript-conversation-events/findings.md b/.planning/2026-08-27-javascript-conversation-events/findings.md new file mode 100644 index 0000000..0bac0bd --- /dev/null +++ b/.planning/2026-08-27-javascript-conversation-events/findings.md @@ -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` 排序,返回顺序与会话列表一致(置顶优先,其次最近更新)。 diff --git a/.planning/2026-08-27-javascript-conversation-events/progress.md b/.planning/2026-08-27-javascript-conversation-events/progress.md new file mode 100644 index 0000000..dfd5755 --- /dev/null +++ b/.planning/2026-08-27-javascript-conversation-events/progress.md @@ -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 触发。 diff --git a/.planning/2026-08-27-javascript-conversation-events/task_plan.md b/.planning/2026-08-27-javascript-conversation-events/task_plan.md new file mode 100644 index 0000000..949ea38 --- /dev/null +++ b/.planning/2026-08-27-javascript-conversation-events/task_plan.md @@ -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 测试在旧实现上失败;进入实现阶段。 | diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 0d89dea..4ef0dff 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 3fe97ef..35b4e2e 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -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 = [ diff --git a/lib/javascript-session-runtime.js b/lib/javascript-session-runtime.js index c7b7bf2..8dd65c7 100644 --- a/lib/javascript-session-runtime.js +++ b/lib/javascript-session-runtime.js @@ -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', + 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); diff --git a/scripts/javascript-session-runtime-unit.js b/scripts/javascript-session-runtime-unit.js index 5a9fb98..e1ef210 100644 --- a/scripts/javascript-session-runtime-unit.js +++ b/scripts/javascript-session-runtime-unit.js @@ -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); diff --git a/server.js b/server.js index 890e3e8..de32ca1 100644 --- a/server.js +++ b/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();