beeos-claw 0.5.55 → 0.5.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-gateway/assistant-mq-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-create.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core-apply-template.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-channel-reuse.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-internal-delivery.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-meta-hydration.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-timeout.test.js +1 -1
- package/dist/acp-gateway/bridge-core-history-page.test.js +1 -1
- package/dist/acp-gateway/bridge-core-persisted-tool-result.test.js +1 -1
- package/dist/acp-gateway/bridge-core-ping.test.js +1 -1
- package/dist/acp-gateway/bridge-core-session-mutation-lifecycle.test.js +1 -0
- package/dist/acp-gateway/bridge-core-session-new.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-backfill.test.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-name.test.js +1 -1
- package/dist/acp-gateway/gateway-events-file-artifact.test.js +1 -1
- package/dist/acp-gateway/gateway-events-heartbeat.test.js +1 -1
- package/dist/acp-gateway/gateway-events.js +1 -1
- package/dist/acp-gateway/session-load-inflight-user.test.js +1 -1
- package/dist/acp-gateway/session-load.test.js +1 -1
- package/dist/acp-server-agent-sync.test.js +1 -1
- package/dist/acp-server-bridge-shim.test.js +1 -1
- package/dist/acp-server.js +1 -1
- package/dist/canvas-reregister-session-race.test.js +1 -1
- package/dist/im-inbound-handler.js +1 -1
- package/dist/im-inbound-handler.test.js +1 -1
- package/dist/im-runtime-dispatch-session-guard.test.js +1 -1
- package/dist/im-runtime-dispatch.js +1 -1
- package/dist/im-runtime-dispatch.test.js +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/im-reply-sink.js +1 -1
- package/dist/runtime/im-reply-sink.test.js +1 -1
- package/dist/runtime/lifecycle-promise-observer.js +1 -0
- package/dist/runtime/lifecycle-promise-observer.test.js +1 -0
- package/dist/runtime/prompt-run-store.js +1 -1
- package/dist/runtime/prompt-run-store.test.js +1 -1
- package/dist/runtime/session-mutation-coordinator.js +1 -0
- package/dist/runtime/session-mutation-coordinator.test.js +1 -0
- package/dist/test-fixtures/im-runtime.fixture.js +1 -1
- package/dist/test-fixtures/session-mutation.fixture.js +1 -0
- package/package.json +1 -1
- package/dist/runtime/session-write-gate-release-coordinator.js +0 -1
- package/dist/runtime/session-write-gate-release-coordinator.test.js +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{classifyAgentRuntimeFailure}from"../runtime/agent-runtime-failure-classifier.js";import{stripTransportMetadata,normalizeTransportText,previewSanitizedText}from"../message-sanitization/transport-metadata-sanitizer.js";import{classifyHeartbeatFilter}from"../message-sanitization/message-classifier.js";import{emitNormalizedToolResultForTurn}from"./tool-result-emitter.js";import{buildToolCallPayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const n=roleFromSessionUpdate(e);n&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[n]=s)}export function recordToolCallTurnForUpdate(t,e,s){s&&"tool_call"===e.sessionUpdate&&e.toolCallId&&(t.toolCallTurns??=new Map,t.toolCallTurns.set(e.toolCallId,{turnId:s,..."string"==typeof e.name&&e.name?{toolName:e.name}:{}}))}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;interruptPrompt;schedulePromptTimeout;maybeCompletePromptFromChatState;agentFetchFn;getMessageClient;getMsgPrincipalId;gatewayRequest;getCanvasState;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.interruptPrompt=t.interruptPrompt,this.schedulePromptTimeout=t.schedulePromptTimeout,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.getMessageClient=t.getMessageClient,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.getCanvasState=t.getCanvasState,this.cronChannelCache=t.cronChannelCache}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.interruptPrompt(t,classifyAgentRuntimeFailure({raw:`Stream stale timeout after ${e}ms`,source:"live_gateway",runtimeKind:"openclaw"})))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),recordToolCallTurnForUpdate(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s)}resolveToolCallId(t,e,s){const n=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(n){const e=this.state.toolCallIdMap.get(n);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(n,n),t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n)}const o=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const n of s.content){const o=n;if(!isRecord(o))continue;const a=asString(o.type);if(!a)continue;const i=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(o.thinking)??normalizeText(o.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const n={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...i?{timestamp:i}:{}};this.sendTurnUpdate(e,n)&&e.assistantMq?.push(n);continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,i),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,o,"start"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=resolveToolArgs(o),a=dedupKey("chat:tool_start",t,JSON.stringify(n));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const r=buildToolCallPayload({toolCallId:t,toolName:s,args:n,timestamp:i});this.sendTurnUpdate(e,r)&&e.assistantMq?.push(r);continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,i),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,o,"result"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=o.result,a=normalizeText(o.text)??normalizeText(o.result)??(isRecord(n)||Array.isArray(n)?JSON.stringify(n,null,2):void 0);if(!a)continue;const r=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(r))continue;e.chatReplayDedupKeys.add(r),this.emitNormalizedToolResult(e,{toolCallId:t,toolName:s,rawText:a,rawResult:n,timestamp:i}).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result normalization failed: ${t instanceof Error?t.message:t}`)});continue}if("text"===a&&o.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(o.text);if(!s)continue;const n=classifyHeartbeatFilter(s);if(n.filtered){this.logHeartbeatSkip({source:"chat-text",reason:n.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const r=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(r))l=s.slice(r.length);else{if(r.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${r.length}`);const d={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...i?{timestamp:i}:{}};this.sendTurnUpdate(e,d),e.assistantMq?.push(d)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message,n=classifyAgentRuntimeFailure({raw:s,source:"chat_state",runtimeKind:"openclaw"});"interrupted"===n.kind?this.interruptPrompt(e,n):this.failPrompt(e,Errors.AgentError.rpcCode,n.publicMessage)}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}t.runId&&e.cronJobId&&!this.state.runIdToCronJobId.has(t.runId)&&this.state.setRunCronJobId(t.runId,e.cronJobId);e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),n=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const o=this.state.extractAgentAssistantDelta(e,t.data??{});if(o){const a=normalizeTransportText(o);if(a){const o=e.agentAssistantTextSoFar??a,i=classifyHeartbeatFilter(o);i.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:i.reason,sessionId:e.sessionId,runId:t.runId,cumulative:o,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),n({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],i=t.data;i&&(Array.isArray(i.content)?a.push(...i.content):isRecord(i.content)?a.push(i.content):"string"==typeof i.type&&a.push(i));for(const t of a){if(!isRecord(t))continue;const o=asString(t.type);if("thinking"===o){e.hasAgentThinkingStream=!0;const n=normalizeText(t.thinking)??normalizeText(t.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}continue}if("toolCall"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const o=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",i=resolveToolArgs(t);n(buildToolCallPayload({toolCallId:o,toolName:a,args:i,timestamp:s}));continue}if("toolResult"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const o=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",i=t.result,r=normalizeText(t.text)??normalizeText(t.result)??(isRecord(i)||Array.isArray(i)?JSON.stringify(i,null,2):void 0);r&&this.emitNormalizedToolResult(e,{toolCallId:o,toolName:a,rawText:r,rawResult:i,timestamp:s,pushUpdate:n}).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result normalization failed: ${t instanceof Error?t.message:t}`)});continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const n=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}}else if("tool"===t.stream){const o=t.data,a="string"==typeof o?.phase?o.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,o,"start"),a=asString(o?.name)??asString(o?.toolName)??asString(o?.tool_name)??"tool",i=resolveToolArgs(o);n(buildToolCallPayload({toolCallId:t,toolName:a,args:i,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=o?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(o?.name)??asString(o?.toolName)??"tool",i={...o,...t},r=this.resolveToolCallId(e,i,"start"),l=resolveToolArgs(i);n(buildToolCallPayload({toolCallId:r,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,n="string"==typeof s?.phase?s.phase:void 0;if("end"===n){const t="string"==typeof s?.stopReason?s.stopReason:void 0;this.completePrompt(e,t||"end_turn")}else if("cancelled"===n||"cancel"===n)this.completePrompt(e,"cancelled");else if("error"===n){const n=s?.error,o=n&&"object"==typeof n?n:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof n?n:void 0)??("string"==typeof o?.message?o.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(o?JSON.stringify(o):void 0)??"gateway lifecycle error";this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`);const i=classifyAgentRuntimeFailure({raw:a,source:"live_gateway",runtimeKind:"openclaw"});"interrupted"===i.kind?this.interruptPrompt(e,i):this.failPrompt(e,Errors.AgentError.rpcCode,i.publicMessage)}}}async handleToolResult(t,e,s){const n=e.data,o=n?.toolCall,a=("string"==typeof o?.id?o.id:void 0)??("string"==typeof n?.toolCallId?n.toolCallId:void 0),i=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,r=("string"==typeof o?.name?o.name:void 0)??("string"==typeof n?.name?n.name:void 0)??"tool",l="string"==typeof n?.text?n.text:void 0,d=a??i,c=n?.result;await this.emitNormalizedToolResult(t,{toolCallId:i,normalizeToolCallId:d,toolName:r,rawText:l,rawResult:c,timestamp:s})}async emitNormalizedToolResult(t,e){const s=e.pushUpdate??(e=>{this.sendTurnUpdate(t,e)&&t.assistantMq?.push(e)});await emitNormalizedToolResultForTurn({session:t,envelope:{toolCallId:e.toolCallId,toolName:e.toolName,rawText:e.rawText,rawResult:e.rawResult,timestamp:e.timestamp},normalizeToolCallId:e.normalizeToolCallId,turnId:resolveTurnId(t),config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:this.getCanvasState,sendUpdate:t=>(s(t),!0)})}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t);const s=this.state.runIdToCronJobId.get(e);s&&(t.cronJobId=s),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.presentedFileArtifactKeys=new Set,t.emittedToolResultKeys=new Set,t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue),this.schedulePromptTimeout(t)}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{};"string"==typeof e.runId&&"string"==typeof e.jobId&&this.state.setRunCronJobId(e.runId,e.jobId),this.config.agentGatewayUrl&&this.agentFetchFn&&this.reportCronEventToAgentGateway(e),"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async reportCronEventToAgentGateway(t){if(!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e={...t},s="string"==typeof t.sessionKey?t.sessionKey:void 0;s&&null==e.sessionKey&&(e.sessionKey=s);const n=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(n&&(e.sessionId=n),("string"!=typeof e.name||!e.name)&&"string"==typeof t.jobId&&t.jobId&&this.gatewayRequest)try{const s=await this.gatewayRequest("cron.list",{}),n=s?.jobs;if(Array.isArray(n))for(const s of n)if(isRecord(s)&&s.id===t.jobId&&"string"==typeof s.name&&s.name){e.name=s.name;break}}catch(t){this.logger.debug?.(`[gateway-events] cron.list lookup failed: ${t instanceof Error?t.message:t}`)}const o=`${this.config.agentGatewayUrl}/api/v1/agent/events`;try{await this.agentFetchFn(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:e})})}catch(t){this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)}}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),n=t?.jobs;if(Array.isArray(n))for(const t of n){if(!t||"object"!=typeof t)continue;const n=t;if(n.id===e){s=n;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const n="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(n)){const t=decodeBeeosChannelId(n);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const o=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(o&&"string"==typeof o.kind?o.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const i="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!i)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const r=this.state.findSessionByKey(i),l=r?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${i} job=${e}`);const d=encodeBeeosChannelTag(n,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:d}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageClient?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const n="string"==typeof t.jobId?t.jobId:void 0;if(!n)return;const o=t.summary;let a=this.cronChannelCache?.get(n);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===n){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const i=randomUUID();try{await e.messages.send({conversationId:a,id:i,type:"agent_reply",content:{text:o,cron_job_id:n,session_key:t.sessionKey??void 0}}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${n}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${n} err=${e}`),void this.cronChannelCache?.delete(n);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewSanitizedText(t.cumulative),s=void 0!==t.delta?previewSanitizedText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
|
|
1
|
+
import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{classifyAgentRuntimeFailure}from"../runtime/agent-runtime-failure-classifier.js";import{stripTransportMetadata,normalizeTransportText,previewSanitizedText}from"../message-sanitization/transport-metadata-sanitizer.js";import{classifyHeartbeatFilter}from"../message-sanitization/message-classifier.js";import{emitNormalizedToolResultForTurn}from"./tool-result-emitter.js";import{buildToolCallPayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";import{observeLifecyclePromise}from"../runtime/lifecycle-promise-observer.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const o=roleFromSessionUpdate(e);o&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[o]=s)}export function recordToolCallTurnForUpdate(t,e,s){s&&"tool_call"===e.sessionUpdate&&e.toolCallId&&(t.toolCallTurns??=new Map,t.toolCallTurns.set(e.toolCallId,{turnId:s,..."string"==typeof e.name&&e.name?{toolName:e.name}:{}}))}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;interruptPrompt;schedulePromptTimeout;adoptBackgroundMutation;abortGatewayRun;maybeCompletePromptFromChatState;agentFetchFn;getMessageClient;getMsgPrincipalId;gatewayRequest;getCanvasState;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.interruptPrompt=t.interruptPrompt,this.schedulePromptTimeout=t.schedulePromptTimeout,this.adoptBackgroundMutation=t.adoptBackgroundMutation,this.abortGatewayRun=t.abortGatewayRun,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.getMessageClient=t.getMessageClient,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.getCanvasState=t.getCanvasState,this.cronChannelCache=t.cronChannelCache}observeLifecycle(t,e,s){observeLifecyclePromise(t,this.logger,{component:"gateway-events",action:e,sessionId:s.sessionId,gatewaySessionKey:s.gatewaySessionKey})}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.observeLifecycle(this.interruptPrompt(t,classifyAgentRuntimeFailure({raw:`Stream stale timeout after ${e}ms`,source:"live_gateway",runtimeKind:"openclaw"})),"stream_stale_timeout",t))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),recordToolCallTurnForUpdate(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s)}resolveToolCallId(t,e,s){const o=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(o){const e=this.state.toolCallIdMap.get(o);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(o,o),t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o)}const n=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;if(!this.adoptBackgroundRun(e,t.runId))return}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const o of s.content){const n=o;if(!isRecord(n))continue;const a=asString(n.type);if(!a)continue;const i=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(n.thinking)??normalizeText(n.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const o={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...i?{timestamp:i}:{}};this.sendTurnUpdate(e,o)&&e.assistantMq?.push(o);continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,i),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,n,"start"),s=asString(n.name)??asString(n.toolName)??asString(n.tool_name)??"tool",o=resolveToolArgs(n),a=dedupKey("chat:tool_start",t,JSON.stringify(o));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const r=buildToolCallPayload({toolCallId:t,toolName:s,args:o,timestamp:i});this.sendTurnUpdate(e,r)&&e.assistantMq?.push(r);continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,i),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,n,"result"),s=asString(n.name)??asString(n.toolName)??asString(n.tool_name)??"tool",o=n.result,a=normalizeText(n.text)??normalizeText(n.result)??(isRecord(o)||Array.isArray(o)?JSON.stringify(o,null,2):void 0);if(!a)continue;const r=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(r))continue;e.chatReplayDedupKeys.add(r),this.emitNormalizedToolResult(e,{toolCallId:t,toolName:s,rawText:a,rawResult:o,timestamp:i}).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result normalization failed: ${t instanceof Error?t.message:t}`)});continue}if("text"===a&&n.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(n.text);if(!s)continue;const o=classifyHeartbeatFilter(s);if(o.filtered){this.logHeartbeatSkip({source:"chat-text",reason:o.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const r=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(r))l=s.slice(r.length);else{if(r.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${r.length}`);const c={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...i?{timestamp:i}:{}};this.sendTurnUpdate(e,c),e.assistantMq?.push(c)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.observeLifecycle(this.completePrompt(e,t.stopReason||"end_turn"),"chat_final",e);else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.observeLifecycle(this.completePrompt(e,"cancelled"),"chat_cancelled",e);else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message,o=classifyAgentRuntimeFailure({raw:s,source:"chat_state",runtimeKind:"openclaw"});"interrupted"===o.kind?this.observeLifecycle(this.interruptPrompt(e,o),"chat_interrupt",e):this.observeLifecycle(this.failPrompt(e,Errors.AgentError.rpcCode,o.publicMessage),"chat_failure",e)}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;if(!this.adoptBackgroundRun(e,t.runId))return}t.runId&&e.cronJobId&&!this.state.runIdToCronJobId.has(t.runId)&&this.state.setRunCronJobId(t.runId,e.cronJobId);e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),o=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const n=this.state.extractAgentAssistantDelta(e,t.data??{});if(n){const a=normalizeTransportText(n);if(a){const n=e.agentAssistantTextSoFar??a,i=classifyHeartbeatFilter(n);i.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:i.reason,sessionId:e.sessionId,runId:t.runId,cumulative:n,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),o({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],i=t.data;i&&(Array.isArray(i.content)?a.push(...i.content):isRecord(i.content)?a.push(i.content):"string"==typeof i.type&&a.push(i));for(const t of a){if(!isRecord(t))continue;const n=asString(t.type);if("thinking"===n){e.hasAgentThinkingStream=!0;const o=normalizeText(t.thinking)??normalizeText(t.text);if(o){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:o},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}continue}if("toolCall"===n){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const n=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",i=resolveToolArgs(t);o(buildToolCallPayload({toolCallId:n,toolName:a,args:i,timestamp:s}));continue}if("toolResult"===n){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const n=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",i=t.result,r=normalizeText(t.text)??normalizeText(t.result)??(isRecord(i)||Array.isArray(i)?JSON.stringify(i,null,2):void 0);r&&this.emitNormalizedToolResult(e,{toolCallId:n,toolName:a,rawText:r,rawResult:i,timestamp:s,pushUpdate:o}).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result normalization failed: ${t instanceof Error?t.message:t}`)});continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const o=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(o){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:o},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}}else if("tool"===t.stream){const n=t.data,a="string"==typeof n?.phase?n.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,n,"start"),a=asString(n?.name)??asString(n?.toolName)??asString(n?.tool_name)??"tool",i=resolveToolArgs(n);o(buildToolCallPayload({toolCallId:t,toolName:a,args:i,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=n?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(n?.name)??asString(n?.toolName)??"tool",i={...n,...t},r=this.resolveToolCallId(e,i,"start"),l=resolveToolArgs(i);o(buildToolCallPayload({toolCallId:r,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,o="string"==typeof s?.phase?s.phase:void 0;if("end"===o){const t="string"==typeof s?.stopReason?s.stopReason:void 0;this.observeLifecycle(this.completePrompt(e,t||"end_turn"),"agent_final",e)}else if("cancelled"===o||"cancel"===o)this.observeLifecycle(this.completePrompt(e,"cancelled"),"agent_cancelled",e);else if("error"===o){const o=s?.error,n=o&&"object"==typeof o?o:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof o?o:void 0)??("string"==typeof n?.message?n.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(n?JSON.stringify(n):void 0)??"gateway lifecycle error";this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`);const i=classifyAgentRuntimeFailure({raw:a,source:"live_gateway",runtimeKind:"openclaw"});"interrupted"===i.kind?this.observeLifecycle(this.interruptPrompt(e,i),"agent_interrupt",e):this.observeLifecycle(this.failPrompt(e,Errors.AgentError.rpcCode,i.publicMessage),"agent_failure",e)}}}async handleToolResult(t,e,s){const o=e.data,n=o?.toolCall,a=("string"==typeof n?.id?n.id:void 0)??("string"==typeof o?.toolCallId?o.toolCallId:void 0),i=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,r=("string"==typeof n?.name?n.name:void 0)??("string"==typeof o?.name?o.name:void 0)??"tool",l="string"==typeof o?.text?o.text:void 0,c=a??i,d=o?.result;await this.emitNormalizedToolResult(t,{toolCallId:i,normalizeToolCallId:c,toolName:r,rawText:l,rawResult:d,timestamp:s})}async emitNormalizedToolResult(t,e){const s=e.pushUpdate??(e=>{this.sendTurnUpdate(t,e)&&t.assistantMq?.push(e)});await emitNormalizedToolResultForTurn({session:t,envelope:{toolCallId:e.toolCallId,toolName:e.toolName,rawText:e.rawText,rawResult:e.rawResult,timestamp:e.timestamp},normalizeToolCallId:e.normalizeToolCallId,turnId:resolveTurnId(t),config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:this.getCanvasState,sendUpdate:t=>(s(t),!0)})}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`);if(!this.adoptBackgroundMutation(t,e).ok)return this.observeLifecycle(this.abortGatewayRun(t.gatewaySessionKey,e),"background_run_conflict_abort",t),!1;t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t);const s=this.state.runIdToCronJobId.get(e);return s&&(t.cronJobId=s),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.presentedFileArtifactKeys=new Set,t.emittedToolResultKeys=new Set,t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue),this.schedulePromptTimeout(t),!0}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{};"string"==typeof e.runId&&"string"==typeof e.jobId&&this.state.setRunCronJobId(e.runId,e.jobId),this.config.agentGatewayUrl&&this.agentFetchFn&&this.reportCronEventToAgentGateway(e),"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async reportCronEventToAgentGateway(t){if(!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e={...t},s="string"==typeof t.sessionKey?t.sessionKey:void 0;s&&null==e.sessionKey&&(e.sessionKey=s);const o=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(o&&(e.sessionId=o),("string"!=typeof e.name||!e.name)&&"string"==typeof t.jobId&&t.jobId&&this.gatewayRequest)try{const s=await this.gatewayRequest("cron.list",{}),o=s?.jobs;if(Array.isArray(o))for(const s of o)if(isRecord(s)&&s.id===t.jobId&&"string"==typeof s.name&&s.name){e.name=s.name;break}}catch(t){this.logger.debug?.(`[gateway-events] cron.list lookup failed: ${t instanceof Error?t.message:t}`)}const n=`${this.config.agentGatewayUrl}/api/v1/agent/events`;try{await this.agentFetchFn(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:e})})}catch(t){this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)}}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),o=t?.jobs;if(Array.isArray(o))for(const t of o){if(!t||"object"!=typeof t)continue;const o=t;if(o.id===e){s=o;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const o="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(o)){const t=decodeBeeosChannelId(o);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const n=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(n&&"string"==typeof n.kind?n.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const i="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!i)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const r=this.state.findSessionByKey(i),l=r?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${i} job=${e}`);const c=encodeBeeosChannelTag(o,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:c}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageClient?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const o="string"==typeof t.jobId?t.jobId:void 0;if(!o)return;const n=t.summary;let a=this.cronChannelCache?.get(o);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===o){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const i=randomUUID();try{await e.messages.send({conversationId:a,id:i,type:"agent_reply",content:{text:n,cron_job_id:o,session_key:t.sessionKey??void 0}}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${o}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${o} err=${e}`),void this.cronChannelCache?.delete(o);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewSanitizedText(t.cumulative),s=void 0!==t.delta?previewSanitizedText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),n=vi.fn(),a=new AcpTransport(n,s);return{core:new AcpGatewayBridgeCore({state:t,transport:a,config:makeConfig(),logger:e,writeObs:s,getGateway:()=>null,instanceId:"test-instance"}),state:t,transport:a,logger:e,writeObs:s,sendBridgeMessage:n}}function registerSession(e,s,t={}){const n={sessionId:s,gatewaySessionKey:`agent:main:${s}`,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set,...t};return e.sessions.set(n.sessionId,n),e.sessionsByGatewayKey.set(n.gatewaySessionKey,n),n}async function callSessionLoad(e,s,t){const n={jsonrpc:"2.0",id:s,method:"session/load",params:{sessionId:t}};await e.handleSessionLoad(n)}function collectEmittedUpdates(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e?.method).map(e=>e.params?.update).filter(e=>!!e)}function userChunkTexts(e){return e.filter(e=>"user_message_chunk"===e.sessionUpdate).map(e=>{const s=e.content;return"text"===s?.type?s.text??"":""})}describe("session/load — in-flight user buffer (Plan B)",()=>{it("replays pendingUserTurnUpdates when history has nothing yet (mirrors: prompt sent, OpenClaw not yet persisted)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:inflight-user");s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"hello"},timestamp:(new Date).toISOString()}],await callSessionLoad(e.core,1,s.sessionId);const t=collectEmittedUpdates(e.sendBridgeMessage);expect(userChunkTexts(t)).toEqual(["hello"])}),it("RETAINS the buffer after handleSessionMessage(role=user) while history still lags (prevents lost user bubble)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:persisted-user",{lastTurnIdByRole:{user:"req:t1"}});s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"hello"},timestamp:(new Date).toISOString()}];const t={sessionKey:s.gatewaySessionKey,messageId:"a1b2c3d4",message:{role:"user",timestamp:Date.now()}};e.core.handleSessionMessage(t),expect(s.pendingUserTurnUpdates).toHaveLength(1),e.sendBridgeMessage.mockClear(),await callSessionLoad(e.core,2,s.sessionId);const n=collectEmittedUpdates(e.sendBridgeMessage);expect(userChunkTexts(n)).toEqual(["hello"])}),it("clears the buffer on session/load once chat.history has indexed the user turn (prevents double-emit)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:history-indexed",{lastTurnIdByRole:{user:"req:t1"}});s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"hello"},timestamp:(new Date).toISOString()}],e.core.handleSessionMessage({sessionKey:s.gatewaySessionKey,messageId:"a1b2c3d4",message:{role:"user",timestamp:Date.now()}}),expect(s.pendingUserTurnUpdates).toHaveLength(1),vi.spyOn(e.core.localSessionHistory,"readEntries").mockReturnValue([{role:"user",content:[{type:"text",text:"hello"}],timestamp:Date.now(),__openclaw:{id:"a1b2c3d4"}}]),await callSessionLoad(e.core,2,s.sessionId),expect(s.pendingUserTurnUpdates).toEqual([])}),it("cleanupPrompt discards pendingUserTurnUpdates so a stale buffer never outlives the run",async()=>{const e=buildCore(),s=registerSession(e.state,"session:cleanup-buffer");s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"stale"},timestamp:(new Date).toISOString()}],e.core.cleanupPrompt(s),expect(s.pendingUserTurnUpdates).toBeUndefined()})});
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),n=vi.fn(),a=new AcpTransport(n,s);return{core:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:a,config:makeConfig(),logger:e,writeObs:s,getGateway:()=>null,instanceId:"test-instance"}),state:t,transport:a,logger:e,writeObs:s,sendBridgeMessage:n}}function registerSession(e,s,t={}){const n={sessionId:s,gatewaySessionKey:`agent:main:${s}`,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set,...t};return e.sessions.set(n.sessionId,n),e.sessionsByGatewayKey.set(n.gatewaySessionKey,n),n}async function callSessionLoad(e,s,t){const n={jsonrpc:"2.0",id:s,method:"session/load",params:{sessionId:t}};await e.handleSessionLoad(n)}function collectEmittedUpdates(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e?.method).map(e=>e.params?.update).filter(e=>!!e)}function userChunkTexts(e){return e.filter(e=>"user_message_chunk"===e.sessionUpdate).map(e=>{const s=e.content;return"text"===s?.type?s.text??"":""})}describe("session/load — in-flight user buffer (Plan B)",()=>{it("replays pendingUserTurnUpdates when history has nothing yet (mirrors: prompt sent, OpenClaw not yet persisted)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:inflight-user");s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"hello"},timestamp:(new Date).toISOString()}],await callSessionLoad(e.core,1,s.sessionId);const t=collectEmittedUpdates(e.sendBridgeMessage);expect(userChunkTexts(t)).toEqual(["hello"])}),it("RETAINS the buffer after handleSessionMessage(role=user) while history still lags (prevents lost user bubble)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:persisted-user",{lastTurnIdByRole:{user:"req:t1"}});s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"hello"},timestamp:(new Date).toISOString()}];const t={sessionKey:s.gatewaySessionKey,messageId:"a1b2c3d4",message:{role:"user",timestamp:Date.now()}};e.core.handleSessionMessage(t),expect(s.pendingUserTurnUpdates).toHaveLength(1),e.sendBridgeMessage.mockClear(),await callSessionLoad(e.core,2,s.sessionId);const n=collectEmittedUpdates(e.sendBridgeMessage);expect(userChunkTexts(n)).toEqual(["hello"])}),it("clears the buffer on session/load once chat.history has indexed the user turn (prevents double-emit)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:history-indexed",{lastTurnIdByRole:{user:"req:t1"}});s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"hello"},timestamp:(new Date).toISOString()}],e.core.handleSessionMessage({sessionKey:s.gatewaySessionKey,messageId:"a1b2c3d4",message:{role:"user",timestamp:Date.now()}}),expect(s.pendingUserTurnUpdates).toHaveLength(1),vi.spyOn(e.core.localSessionHistory,"readEntries").mockReturnValue([{role:"user",content:[{type:"text",text:"hello"}],timestamp:Date.now(),__openclaw:{id:"a1b2c3d4"}}]),await callSessionLoad(e.core,2,s.sessionId),expect(s.pendingUserTurnUpdates).toEqual([])}),it("cleanupPrompt discards pendingUserTurnUpdates so a stale buffer never outlives the run",async()=>{const e=buildCore(),s=registerSession(e.state,"session:cleanup-buffer");s.pendingUserTurnUpdates=[{sessionUpdate:"user_message_chunk",content:{type:"text",text:"stale"},timestamp:(new Date).toISOString()}],e.core.cleanupPrompt(s),expect(s.pendingUserTurnUpdates).toBeUndefined()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";import{MessageQueue}from"../utils/mq.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";import{MessageQueue}from"../utils/mq.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),n=vi.fn(),a=new AcpTransport(n,s);return{core:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:a,config:makeConfig(),logger:e,writeObs:s,getGateway:()=>null,instanceId:"test-instance"}),state:t,transport:a,logger:e,writeObs:s,sendBridgeMessage:n}}function registerSession(e,s,t={}){const n={sessionId:s,gatewaySessionKey:`agent:main:${s}`,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set,...t};return e.sessions.set(n.sessionId,n),e.sessionsByGatewayKey.set(n.gatewaySessionKey,n),n}async function callSessionLoad(e,s,t){const n={jsonrpc:"2.0",id:s,method:"session/load",params:{sessionId:t}};await e.handleSessionLoad(n)}function collectEmittedUpdates(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e?.method).map(e=>e.params?.update)}function collectResults(e){return e.mock.calls.map(e=>e[0]).filter(e=>void 0!==e?.result)}describe("session/load — KISS contract",()=>{it("ACKs immediately (< 50ms) even when assistantMq has buffered chunks (regression: Bug-AW hang)",async()=>{const e=buildCore(),s=registerSession(e.state,"session:fast-ack"),t=new MessageQueue;for(let e=0;e<5;e++)t.push({sessionUpdate:"agent_message_chunk",content:{type:"text",text:`chunk-${e}`}});s.assistantMq=t;const n=Date.now();await callSessionLoad(e.core,1,s.sessionId);const a=Date.now()-n;expect(a).toBeLessThan(50);const o=collectResults(e.sendBridgeMessage);expect(o).toHaveLength(1),expect(o[0].result).toMatchObject({sessionId:s.sessionId})}),it("emits mq snapshot exactly once — subsequent pushes are NOT replayed as part of this load",async()=>{const e=buildCore(),s=registerSession(e.state,"session:snapshot-once"),t=new MessageQueue;t.push({sessionUpdate:"agent_message_chunk",content:{type:"text",text:"first"}}),t.push({sessionUpdate:"agent_message_chunk",content:{type:"text",text:"second"}}),s.assistantMq=t,await callSessionLoad(e.core,1,s.sessionId),t.push({sessionUpdate:"agent_message_chunk",content:{type:"text",text:"post-load"}});const n=collectEmittedUpdates(e.sendBridgeMessage).filter(e=>"agent_message_chunk"===e?.sessionUpdate).map(e=>e.content.text);expect(n).toEqual(["first","second"])}),it("emits no stream updates when assistantMq is undefined — just ACKs",async()=>{const e=buildCore(),s=registerSession(e.state,"session:no-mq");s.assistantMq=void 0,await callSessionLoad(e.core,1,s.sessionId);const t=collectEmittedUpdates(e.sendBridgeMessage);expect(t.filter(e=>"agent_message_chunk"===e?.sessionUpdate)).toHaveLength(0);const n=collectResults(e.sendBridgeMessage);expect(n).toHaveLength(1)}),it("skips snapshot when assistantMq is closed — closed mq means history already covers the turn",async()=>{const e=buildCore(),s=registerSession(e.state,"session:closed-mq"),t=new MessageQueue;t.push({sessionUpdate:"agent_message_chunk",content:{type:"text",text:"should-be-skipped"}}),t.close(),s.assistantMq=t,await callSessionLoad(e.core,1,s.sessionId);const n=collectEmittedUpdates(e.sendBridgeMessage);expect(n.filter(e=>"agent_message_chunk"===e?.sessionUpdate)).toHaveLength(0);const a=collectResults(e.sendBridgeMessage);expect(a).toHaveLength(1)}),it("rejects a load with missing sessionId via sendAcpError (no session touch)",async()=>{const e=buildCore();await e.core.handleSessionLoad({jsonrpc:"2.0",id:1,method:"session/load",params:{}});const s=e.sendBridgeMessage.mock.calls.map(e=>e[0]).filter(e=>void 0!==e?.error);expect(s).toHaveLength(1),expect(s[0].error.data?.param).toBe("sessionId")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it,vi}from"vitest";import{ACPServer}from"./acp-server.js";const gatewayMock=vi.hoisted(()=>({instances:[]}));function makeConfig(){return{agentGatewayUrl:"https://agent-gw.example.test",keyFile:"/tmp/test.key",bridge:{url:"wss://bridge.example.test",service:"acp",shell:{enabled:!1}},gateway:{url:"ws://127.0.0.1:18789",token:"test-token",protocol:3,clientId:"test-client",agentId:"main"},retry:{baseMs:1,maxMs:10,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:1e3,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:1e3,streamStaleTimeoutMs:1e3,cronSignalWindowMs:1e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}vi.mock("./gateway-client.js",()=>({GatewayClient:class{connected=!1;request=vi.fn();handlers=new Map;constructor(){gatewayMock.instances.push(this)}get isConnected(){return this.connected}on(e,t){const a=this.handlers.get(e)??[];return a.push(t),this.handlers.set(e,a),this}async start(){}stop(){this.connected=!1}emit(e,t){for(const a of this.handlers.get(e)??[])a(t)}}})),describe("ACPServer agent sync recovery",()=>{it("declares push delivery mode when message bus is enabled",async()=>{gatewayMock.instances.length=0;const e=vi.fn(async(e,t)=>new Response(JSON.stringify({created:0,updated:1,deactivated:0,data:[{id:"platform-main",instanceId:"inst_test",name:"main",displayName:"main",description:"Main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),t=makeConfig();t.messageBus.enabled=!0,t.messageBus.serviceUrl="https://msg.example.test";const a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(t,a,void 0,e,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.connected=!0,s.request.mockImplementation(async e=>{if("agents.list"===e)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"main",name:"Main"}]};if("skills.status"===e)return{skills:[]};throw new Error(`unexpected method: ${e}`)}),n.client={readyState:1},await n.tryAgentSync();const i=JSON.parse(String(e.mock.calls[0]?.[1]?.body));expect(i.agents[0].deliveryMode).toBe("push")}),it("uses the local OpenClaw agent key when agents.list id is a platform UUID",async()=>{gatewayMock.instances.length=0;const e=vi.fn(async(e,t)=>new Response(JSON.stringify({created:0,updated:1,deactivated:1,data:[{id:"25aca934-5e41-4464-b142-157c779cba93",instanceId:"inst_test",name:"main",displayName:"OpenClaw",description:"main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),t=makeConfig();t.messageBus.enabled=!0,t.messageBus.serviceUrl="https://msg.example.test";const a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(t,a,void 0,e,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.connected=!0,s.request.mockImplementation(async(e,t)=>{if("agents.list"===e)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"25aca934-5e41-4464-b142-157c779cba93",name:"main",identity:{name:"OpenClaw"}}]};if("skills.status"===e)return expect(t?.agentId).toBe("25aca934-5e41-4464-b142-157c779cba93"),{skills:[]};throw new Error(`unexpected method: ${e}`)}),n.client={readyState:1},await n.tryAgentSync();const i=JSON.parse(String(e.mock.calls[0]?.[1]?.body));expect(i.agents[0]).toEqual(expect.objectContaining({name:"main",displayName:"OpenClaw",deliveryMode:"push"})),expect(n.core.resolvePlatformAgentId("main")).toBe("25aca934-5e41-4464-b142-157c779cba93")}),it("skips platform UUID shadow agents when a local agent key is present",async()=>{gatewayMock.instances.length=0;const e=vi.fn(async(e,t)=>new Response(JSON.stringify({created:0,updated:1,deactivated:1,data:[{id:"25aca934-5e41-4464-b142-157c779cba93",instanceId:"inst_test",name:"main",displayName:"main",description:"main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),t=makeConfig();t.messageBus.enabled=!0,t.messageBus.serviceUrl="https://msg.example.test";const a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(t,a,void 0,e,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.connected=!0,s.request.mockImplementation(async(e,t)=>{if("agents.list"===e)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"main",name:"main"},{id:"25aca934-5e41-4464-b142-157c779cba93",name:"25aca934-5e41-4464-b142-157c779cba93"}]};if("skills.status"===e)return expect(t?.agentId).toBe("main"),{skills:[]};throw new Error(`unexpected method: ${e}`)}),n.client={readyState:1},await n.tryAgentSync();const i=JSON.parse(String(e.mock.calls[0]?.[1]?.body));expect(i.agents).toHaveLength(1),expect(i.agents[0].name).toBe("main"),expect(a.debug).toHaveBeenCalledWith("[agent-sync] skipping platform-id shadow agent 25aca934-5e41-4464-b142-157c779cba93")}),it("retries agent sync when OpenClaw Gateway reconnects after initial wait-gateway failure",async()=>{gatewayMock.instances.length=0;let e=!1;const t=vi.fn(async()=>new Response(JSON.stringify({created:1,updated:0,deactivated:0,data:[{id:"platform-main",instanceId:"inst_test",name:"main",displayName:"main",description:"Main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(makeConfig(),a,void 0,t,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.request.mockImplementation(async t=>{if(!e)throw new Error("Not connected to Gateway");if("agents.list"===t)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"main",name:"Main"}]};if("skills.status"===t)return{skills:[]};if("cron.list"===t)return{jobs:[]};throw new Error(`unexpected method: ${t}`)}),n.client={readyState:1},await n.tryAgentSync(),expect(t).not.toHaveBeenCalled(),expect(a.info).toHaveBeenCalledWith("[acp-server] agent sync waiting for OpenClaw Gateway connection"),e=!0,s.connected=!0,s.emit("connected"),await vi.waitFor(()=>expect(t).toHaveBeenCalledTimes(1)),expect(t).toHaveBeenCalledWith(new URL("/api/v1/agents/sync","https://agent-gw.example.test"),expect.objectContaining({method:"POST"})),expect(a.info).toHaveBeenCalledWith(expect.stringContaining("[acp-server] agent mapping loaded: main"))})});
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"./test-fixtures/session-mutation.fixture.js";import{ACPServer}from"./acp-server.js";const gatewayMock=vi.hoisted(()=>({instances:[]}));function makeConfig(){return{agentGatewayUrl:"https://agent-gw.example.test",keyFile:"/tmp/test.key",bridge:{url:"wss://bridge.example.test",service:"acp",shell:{enabled:!1}},gateway:{url:"ws://127.0.0.1:18789",token:"test-token",protocol:3,clientId:"test-client",agentId:"main"},retry:{baseMs:1,maxMs:10,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:1e3,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:1e3,streamStaleTimeoutMs:1e3,cronSignalWindowMs:1e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}vi.mock("./gateway-client.js",()=>({GatewayClient:class{connected=!1;request=vi.fn();handlers=new Map;constructor(){gatewayMock.instances.push(this)}get isConnected(){return this.connected}on(e,t){const a=this.handlers.get(e)??[];return a.push(t),this.handlers.set(e,a),this}async start(){}stop(){this.connected=!1}emit(e,t){for(const a of this.handlers.get(e)??[])a(t)}}})),describe("ACPServer agent sync recovery",()=>{it("declares push delivery mode when message bus is enabled",async()=>{gatewayMock.instances.length=0;const e=vi.fn(async(e,t)=>new Response(JSON.stringify({created:0,updated:1,deactivated:0,data:[{id:"platform-main",instanceId:"inst_test",name:"main",displayName:"main",description:"Main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),t=makeConfig();t.messageBus.enabled=!0,t.messageBus.serviceUrl="https://msg.example.test";const a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(t,a,createTestSessionMutationCoordinator().coordinator,void 0,e,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.connected=!0,s.request.mockImplementation(async e=>{if("agents.list"===e)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"main",name:"Main"}]};if("skills.status"===e)return{skills:[]};throw new Error(`unexpected method: ${e}`)}),n.client={readyState:1},await n.tryAgentSync();const i=JSON.parse(String(e.mock.calls[0]?.[1]?.body));expect(i.agents[0].deliveryMode).toBe("push")}),it("uses the local OpenClaw agent key when agents.list id is a platform UUID",async()=>{gatewayMock.instances.length=0;const e=vi.fn(async(e,t)=>new Response(JSON.stringify({created:0,updated:1,deactivated:1,data:[{id:"25aca934-5e41-4464-b142-157c779cba93",instanceId:"inst_test",name:"main",displayName:"OpenClaw",description:"main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),t=makeConfig();t.messageBus.enabled=!0,t.messageBus.serviceUrl="https://msg.example.test";const a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(t,a,createTestSessionMutationCoordinator().coordinator,void 0,e,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.connected=!0,s.request.mockImplementation(async(e,t)=>{if("agents.list"===e)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"25aca934-5e41-4464-b142-157c779cba93",name:"main",identity:{name:"OpenClaw"}}]};if("skills.status"===e)return expect(t?.agentId).toBe("25aca934-5e41-4464-b142-157c779cba93"),{skills:[]};throw new Error(`unexpected method: ${e}`)}),n.client={readyState:1},await n.tryAgentSync();const i=JSON.parse(String(e.mock.calls[0]?.[1]?.body));expect(i.agents[0]).toEqual(expect.objectContaining({name:"main",displayName:"OpenClaw",deliveryMode:"push"})),expect(n.core.resolvePlatformAgentId("main")).toBe("25aca934-5e41-4464-b142-157c779cba93")}),it("skips platform UUID shadow agents when a local agent key is present",async()=>{gatewayMock.instances.length=0;const e=vi.fn(async(e,t)=>new Response(JSON.stringify({created:0,updated:1,deactivated:1,data:[{id:"25aca934-5e41-4464-b142-157c779cba93",instanceId:"inst_test",name:"main",displayName:"main",description:"main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),t=makeConfig();t.messageBus.enabled=!0,t.messageBus.serviceUrl="https://msg.example.test";const a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(t,a,createTestSessionMutationCoordinator().coordinator,void 0,e,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.connected=!0,s.request.mockImplementation(async(e,t)=>{if("agents.list"===e)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"main",name:"main"},{id:"25aca934-5e41-4464-b142-157c779cba93",name:"25aca934-5e41-4464-b142-157c779cba93"}]};if("skills.status"===e)return expect(t?.agentId).toBe("main"),{skills:[]};throw new Error(`unexpected method: ${e}`)}),n.client={readyState:1},await n.tryAgentSync();const i=JSON.parse(String(e.mock.calls[0]?.[1]?.body));expect(i.agents).toHaveLength(1),expect(i.agents[0].name).toBe("main"),expect(a.debug).toHaveBeenCalledWith("[agent-sync] skipping platform-id shadow agent 25aca934-5e41-4464-b142-157c779cba93")}),it("retries agent sync when OpenClaw Gateway reconnects after initial wait-gateway failure",async()=>{gatewayMock.instances.length=0;let e=!1;const t=vi.fn(async()=>new Response(JSON.stringify({created:1,updated:0,deactivated:0,data:[{id:"platform-main",instanceId:"inst_test",name:"main",displayName:"main",description:"Main",version:"0.1.0",avatarUrl:"",status:"active"}]}),{status:200,headers:{"Content-Type":"application/json"}})),a={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new ACPServer(makeConfig(),a,createTestSessionMutationCoordinator().coordinator,void 0,t,"inst_test");await n.connectGateway();const s=gatewayMock.instances[0];s.request.mockImplementation(async t=>{if(!e)throw new Error("Not connected to Gateway");if("agents.list"===t)return{defaultId:"main",mainKey:"main",scope:"test",agents:[{id:"main",name:"Main"}]};if("skills.status"===t)return{skills:[]};if("cron.list"===t)return{jobs:[]};throw new Error(`unexpected method: ${t}`)}),n.client={readyState:1},await n.tryAgentSync(),expect(t).not.toHaveBeenCalled(),expect(a.info).toHaveBeenCalledWith("[acp-server] agent sync waiting for OpenClaw Gateway connection"),e=!0,s.connected=!0,s.emit("connected"),await vi.waitFor(()=>expect(t).toHaveBeenCalledTimes(1)),expect(t).toHaveBeenCalledWith(new URL("/api/v1/agents/sync","https://agent-gw.example.test"),expect.objectContaining({method:"POST"})),expect(a.info).toHaveBeenCalledWith(expect.stringContaining("[acp-server] agent mapping loaded: main"))})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it,vi}from"vitest";import{ACPServer}from"./acp-server.js";const bridgeMock=vi.hoisted(()=>({instances:[],closeImpl:vi.fn(async e=>{})}));function makeConfig(){return{agentGatewayUrl:"https://agent-gw.example.test",keyFile:"/tmp/test.key",bridge:{url:"wss://bridge.example.test",service:"acp",shell:{enabled:!1}},gateway:{url:"ws://127.0.0.1:18789",token:"test-token",protocol:3,clientId:"test-client",agentId:"main"},retry:{baseMs:1,maxMs:10,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:1e3,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:1e3,streamStaleTimeoutMs:1e3,cronSignalWindowMs:1e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}vi.mock("@beeos-ai/bridge-client",()=>({BridgeClient:class{state="connected";send=vi.fn(()=>Promise.reject(new Error("ws closing")));connect=vi.fn(async()=>{});close=vi.fn(e=>bridgeMock.closeImpl(e));listeners=new Map;constructor(){bridgeMock.instances.push(this)}on(e,t){const n=this.listeners.get(e)??[];return n.push(t),this.listeners.set(e,n),this}emit(e,...t){for(const n of this.listeners.get(e)??[])n(...t)}}})),vi.mock("./gateway-client.js",()=>({GatewayClient:class{get isConnected(){return!0}on(e,t){return this}removeAllListeners(){return this}async start(){}stop(){}request(){return Promise.resolve({})}agentSend(){return Promise.resolve({})}}})),describe("ACPServer bridge shim",()=>{it("handles BridgeClient.send rejection without creating an unhandled rejection",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{});const e=vi.fn();process.once("unhandledRejection",e);const t=makeLogger(),n=new ACPServer(makeConfig(),t);await n.start(),n.sendBridgeMessage({jsonrpc:"2.0",method:"session/update",params:{}}),await new Promise(e=>setImmediate(e)),process.removeListener("unhandledRejection",e),expect(bridgeMock.instances[0]?.send).toHaveBeenCalledTimes(1),expect(t.warn).toHaveBeenCalledWith("[acp-server] bridge.send failed: ws closing"),expect(e).not.toHaveBeenCalled()})}),describe("ACPServer.stop() bridge teardown",()=>{it("closes the bridge instance even when the ACP-proxy client has already disconnected",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{});const e=makeLogger(),t=new ACPServer(makeConfig(),e);await t.start(),t.client=null,expect(t.bridgeInstance).not.toBeNull(),await t.stop(),expect(bridgeMock.instances[0]?.close).toHaveBeenCalledTimes(1),expect(t.bridgeInstance).toBeNull()}),it("clears bridgeInstance even when bridge.close() rejects",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{throw new Error("simulated SDK close failure")});const e=makeLogger(),t=new ACPServer(makeConfig(),e);await t.start(),await expect(t.stop()).resolves.toBeUndefined(),expect(t.bridgeInstance).toBeNull(),expect(e.debug).toHaveBeenCalledWith(expect.stringContaining("bridge.close failed during stop"))}),it("ignores events from a stale BridgeClient after stop()",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{});const e=makeLogger(),t=new ACPServer(makeConfig(),e);await t.start();const n=bridgeMock.instances[0];expect(n).toBeDefined(),await t.stop(),e.warn.mockClear(),n.emit("disconnected","code=1006"),expect(e.warn).not.toHaveBeenCalled(),expect(bridgeMock.instances).toHaveLength(1)})});
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"./test-fixtures/session-mutation.fixture.js";import{ACPServer}from"./acp-server.js";const bridgeMock=vi.hoisted(()=>({instances:[],closeImpl:vi.fn(async e=>{})}));function makeConfig(){return{agentGatewayUrl:"https://agent-gw.example.test",keyFile:"/tmp/test.key",bridge:{url:"wss://bridge.example.test",service:"acp",shell:{enabled:!1}},gateway:{url:"ws://127.0.0.1:18789",token:"test-token",protocol:3,clientId:"test-client",agentId:"main"},retry:{baseMs:1,maxMs:10,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:1e3,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:1e3,streamStaleTimeoutMs:1e3,cronSignalWindowMs:1e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}vi.mock("@beeos-ai/bridge-client",()=>({BridgeClient:class{state="connected";send=vi.fn(()=>Promise.reject(new Error("ws closing")));connect=vi.fn(async()=>{});close=vi.fn(e=>bridgeMock.closeImpl(e));listeners=new Map;constructor(){bridgeMock.instances.push(this)}on(e,t){const n=this.listeners.get(e)??[];return n.push(t),this.listeners.set(e,n),this}emit(e,...t){for(const n of this.listeners.get(e)??[])n(...t)}}})),vi.mock("./gateway-client.js",()=>({GatewayClient:class{get isConnected(){return!0}on(e,t){return this}removeAllListeners(){return this}async start(){}stop(){}request(){return Promise.resolve({})}agentSend(){return Promise.resolve({})}}})),describe("ACPServer bridge shim",()=>{it("handles BridgeClient.send rejection without creating an unhandled rejection",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{});const e=vi.fn();process.once("unhandledRejection",e);const t=makeLogger(),n=new ACPServer(makeConfig(),t,createTestSessionMutationCoordinator().coordinator);await n.start(),n.sendBridgeMessage({jsonrpc:"2.0",method:"session/update",params:{}}),await new Promise(e=>setImmediate(e)),process.removeListener("unhandledRejection",e),expect(bridgeMock.instances[0]?.send).toHaveBeenCalledTimes(1),expect(t.warn).toHaveBeenCalledWith("[acp-server] bridge.send failed: ws closing"),expect(e).not.toHaveBeenCalled()})}),describe("ACPServer.stop() bridge teardown",()=>{it("closes the bridge instance even when the ACP-proxy client has already disconnected",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{});const e=makeLogger(),t=new ACPServer(makeConfig(),e,createTestSessionMutationCoordinator().coordinator);await t.start(),t.client=null,expect(t.bridgeInstance).not.toBeNull(),await t.stop(),expect(bridgeMock.instances[0]?.close).toHaveBeenCalledTimes(1),expect(t.bridgeInstance).toBeNull()}),it("clears bridgeInstance even when bridge.close() rejects",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{throw new Error("simulated SDK close failure")});const e=makeLogger(),t=new ACPServer(makeConfig(),e,createTestSessionMutationCoordinator().coordinator);await t.start(),await expect(t.stop()).resolves.toBeUndefined(),expect(t.bridgeInstance).toBeNull(),expect(e.debug).toHaveBeenCalledWith(expect.stringContaining("bridge.close failed during stop"))}),it("ignores events from a stale BridgeClient after stop()",async()=>{bridgeMock.instances.length=0,bridgeMock.closeImpl.mockReset(),bridgeMock.closeImpl.mockImplementation(async()=>{});const e=makeLogger(),t=new ACPServer(makeConfig(),e,createTestSessionMutationCoordinator().coordinator);await t.start();const n=bridgeMock.instances[0];expect(n).toBeDefined(),await t.stop(),e.warn.mockClear(),n.emit("disconnected","code=1006"),expect(e.warn).not.toHaveBeenCalled(),expect(bridgeMock.instances).toHaveLength(1)})});
|
package/dist/acp-server.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{sweepStaleSessionLocks}from"./acp-gateway/stale-session-lock-sweeper.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;sessionWriteGate;sessionWriteReleaseCoordinator;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`));const a=await this.sessionWriteGate.acquireInternal({sessionKey:r,label:"agentSend"});let o=!1;try{const s=this.gateway;if(!s?.isConnected)throw new Error("Gateway not connected");if(i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length),void this.sessionWriteGate.release(a);o=!0;const c=await s.agentSend(r,e,n,t);return c?.runId&&i?(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=c.runId,i.assistantStreamStartedAt=Date.now(),i.writeGateToken=a,this.state.promptsByRunId.set(c.runId,i)):this.sessionWriteGate.release(a),c}catch(e){throw o&&this.sessionWriteReleaseCoordinator?this.sessionWriteReleaseCoordinator.releaseAfterQuiescence(a):this.sessionWriteReleaseCoordinator?this.sessionWriteReleaseCoordinator.releaseImmediately(a):this.sessionWriteGate.release(a),e}}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r,a,o){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.sessionWriteGate=a??new SessionWriteGate,this.sessionWriteReleaseCoordinator=o,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const c=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:c,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl,sessionWriteGate:this.sessionWriteGate,sessionWriteReleaseCoordinator:o,triggerAgentSync:()=>this.tryAgentSync()}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),interruptPrompt:(e,t)=>this.core.interruptPrompt(e,t),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,getCanvasState:()=>this.canvasOrch.canvasState,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0,this.sessionWriteReleaseCoordinator?.start();const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`);try{const e=await sweepStaleSessionLocks({logger:this.logger});this.logger.info?.(`[acp-server] stale lock sweep scanned=${e.scanned} quarantined=${e.quarantined} skipped=${e.skipped}`)}catch(e){this.logger.warn?.(`[acp-server] stale lock sweep failed: ${e instanceof Error?e.message:String(e)}`)}if(s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},deliveryMode:this.config.messageBus.enabled?"push":void 0,agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}onAgentMappingUpdated(e){this.core.onAgentMappingUpdated(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentMappings(){const e=this.core.agentMapping;return e&&0!==e.size?[...e.entries()].map(([e,t])=>({localAgentId:e,platformAgent:t})):[]}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.sessionWriteReleaseCoordinator?.stop(),this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
|
|
1
|
+
import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{sweepStaleSessionLocks}from"./acp-gateway/stale-session-lock-sweeper.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;sessionMutationCoordinator;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async chatAbort(e,t){const s=this.gateway;if(!s?.isConnected)throw new Error("Gateway not connected");await s.chatAbort(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`));const a=await this.sessionMutationCoordinator.acquireInternal({sessionKey:r,label:"agentSend"});let o=!1;try{const s=this.gateway;if(!s?.isConnected)throw new Error("Gateway not connected");if(i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length),void this.sessionMutationCoordinator.releaseBeforeRuntime(a);o=!0;const c=await s.agentSend(r,e,n,t);return c?.runId&&i?(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=c.runId,this.sessionMutationCoordinator.bindRun(a,c.runId),i.assistantStreamStartedAt=Date.now(),i.writeGateToken=a,this.state.promptsByRunId.set(c.runId,i)):this.sessionMutationCoordinator.releaseBeforeRuntime(a),c}catch(e){throw o?this.sessionMutationCoordinator.requireRecovery(a,"terminal_not_observed"):this.sessionMutationCoordinator.releaseBeforeRuntime(a),e}}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r,a){this.agentFetchFn=i,this.instanceId=r,this.config=e,this.logger=t,this.writeObs=n??(()=>{}),this.thirdPartyChannels=a??new Set,this.sessionMutationCoordinator=s,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const o=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:o,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl,sessionMutationCoordinator:s,triggerAgentSync:()=>this.tryAgentSync()}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),interruptPrompt:(e,t)=>this.core.interruptPrompt(e,t),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),adoptBackgroundMutation:(e,t)=>this.core.adoptBackgroundMutation(e,t),abortGatewayRun:(e,t)=>this.chatAbort(e,t),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,getCanvasState:()=>this.canvasOrch.canvasState,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>{e.writeGateToken&&this.sessionMutationCoordinator.requireRecovery(e.writeGateToken,"terminal_not_observed"),this.core.cleanupPrompt(e)},e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0;const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`);try{const e=await sweepStaleSessionLocks({logger:this.logger});this.logger.info?.(`[acp-server] stale lock sweep scanned=${e.scanned} quarantined=${e.quarantined} skipped=${e.skipped}`)}catch(e){this.logger.warn?.(`[acp-server] stale lock sweep failed: ${e instanceof Error?e.message:String(e)}`)}if(s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},deliveryMode:this.config.messageBus.enabled?"push":void 0,agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}onAgentMappingUpdated(e){this.core.onAgentMappingUpdated(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentMappings(){const e=this.core.agentMapping;return e&&0!==e.size?[...e.entries()].map(([e,t])=>({localAgentId:e,platformAgent:t})):[]}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.sessionMutationCoordinator.fenceActive("gateway_disconnected"),this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{afterEach,describe,expect,it,vi}from"vitest";import{ACPServer}from"./acp-server.js";import{CanvasStateManager}from"./canvas/state.js";import{createRenderUiHandler}from"./canvas/tools.js";const gatewayMock=vi.hoisted(()=>({instances:[]}));function makeConfig(){return{agentGatewayUrl:"https://agent-gw.example.test",keyFile:"/tmp/test.key",bridge:{url:"wss://bridge.example.test",service:"acp",shell:{enabled:!1}},gateway:{url:"ws://127.0.0.1:18789",token:"test-token",protocol:3,clientId:"test-client",agentId:"main"},retry:{baseMs:1,maxMs:10,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:1e3,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:1e3,streamStaleTimeoutMs:1e3,cronSignalWindowMs:1e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"wss://canvas-relay.example.test",canvasEnabled:!0,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function mockRelay(){return{connected:!0,ensureCanvas:vi.fn().mockResolvedValue("canvas-test"),createSnapshot:vi.fn().mockResolvedValue(void 0)}}function renderParams(){return{id:"world-cup-groups",root:"root",components:[{id:"root",component:{Text:{text:{literalString:"Group A"}}}}]}}function makeServerOpts(e,t){return{stateManager:e,logger:makeLogger(),writeObs:vi.fn(),getActiveSessionId:e=>t.getSessionIdForToolCall(e),getGatewaySessionKey:e=>t.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>t.waitForAgentMapping()}}vi.mock("./gateway-client.js",()=>({GatewayClient:class{connected=!1;request=vi.fn();handlers=new Map;constructor(){gatewayMock.instances.push(this)}get isConnected(){return this.connected}on(e,t){const a=this.handlers.get(e)??[];return a.push(t),this.handlers.set(e,a),this}async start(){}stop(){this.connected=!1}emit(e,t){for(const a of this.handlers.get(e)??[])a(t)}}}));const SESSION_KEY="agent:main:s1",TOOL_CALL_ID="call_00_DWmRWzffCw61OKMxB4hG0823";describe("canvas render_ui across plugin re-register (singleton ACPServer)",()=>{afterEach(()=>{vi.useRealTimers()}),it("survives a re-register: the singleton server is reused, so the session table is preserved",async()=>{const e=new CanvasStateManager;e.setRelayClient(mockRelay());const t=new ACPServer(makeConfig(),makeLogger(),void 0,vi.fn(),"inst_test");t.core.agentMapping=new Map([["main",{id:"main",instanceId:"inst_test",name:"main"}]]),t.setPendingToolCallSessionKey(SESSION_KEY),expect(t.getSessionIdForToolCall(TOOL_CALL_ID)).toBeTruthy();const a=createRenderUiHandler(makeServerOpts(e,t)),s=await a(TOOL_CALL_ID,renderParams());expect(s.success).toBe(!0),expect(t.getSessionIdForToolCall(TOOL_CALL_ID)).toBeTruthy();const n=createRenderUiHandler(makeServerOpts(e,t)),r=await n(TOOL_CALL_ID,renderParams());expect(r.success).toBe(!0),expect(r.surfaceId).toBe("world-cup-groups")}),it("cold start: render_ui recovers once agent-sync lands the session (no fixed-window giveup)",async()=>{const e=new CanvasStateManager;e.setRelayClient(mockRelay());let t=!1;const a=vi.fn(()=>t?"session-after-sync":void 0),s=vi.fn(async()=>{t=!0}),n={stateManager:e,logger:makeLogger(),writeObs:vi.fn(),getActiveSessionId:a,getGatewaySessionKey:()=>{},waitForAgentSync:s},r=createRenderUiHandler(n);vi.useFakeTimers();const o=r(TOOL_CALL_ID,renderParams());await vi.advanceTimersByTimeAsync(1300);const i=await o;expect(s).toHaveBeenCalled(),expect(i.success).toBe(!0),expect(i.surfaceId).toBe("world-cup-groups")}),it("last resort: still returns a RETRYABLE 'not ready' when the session never binds",async()=>{const e=vi.fn(()=>{}),t=vi.fn(async()=>{}),a={stateManager:new CanvasStateManager,logger:makeLogger(),writeObs:vi.fn(),getActiveSessionId:e,getGatewaySessionKey:()=>{},waitForAgentSync:t},s=createRenderUiHandler(a);vi.useFakeTimers();const n=s(TOOL_CALL_ID,renderParams());await vi.advanceTimersByTimeAsync(1300);const r=await n;expect(t).toHaveBeenCalledTimes(1),expect(r.success).toBe(!1),expect(r.retryable).toBe(!0),expect(String(r.error)).toMatch(/not ready/i)})});
|
|
1
|
+
import{afterEach,describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"./test-fixtures/session-mutation.fixture.js";import{ACPServer}from"./acp-server.js";import{CanvasStateManager}from"./canvas/state.js";import{createRenderUiHandler}from"./canvas/tools.js";const gatewayMock=vi.hoisted(()=>({instances:[]}));function makeConfig(){return{agentGatewayUrl:"https://agent-gw.example.test",keyFile:"/tmp/test.key",bridge:{url:"wss://bridge.example.test",service:"acp",shell:{enabled:!1}},gateway:{url:"ws://127.0.0.1:18789",token:"test-token",protocol:3,clientId:"test-client",agentId:"main"},retry:{baseMs:1,maxMs:10,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:1e3,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:1e3,streamStaleTimeoutMs:1e3,cronSignalWindowMs:1e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"wss://canvas-relay.example.test",canvasEnabled:!0,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function mockRelay(){return{connected:!0,ensureCanvas:vi.fn().mockResolvedValue("canvas-test"),createSnapshot:vi.fn().mockResolvedValue(void 0)}}function renderParams(){return{id:"world-cup-groups",root:"root",components:[{id:"root",component:{Text:{text:{literalString:"Group A"}}}}]}}function makeServerOpts(e,t){return{stateManager:e,logger:makeLogger(),writeObs:vi.fn(),getActiveSessionId:e=>t.getSessionIdForToolCall(e),getGatewaySessionKey:e=>t.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>t.waitForAgentMapping()}}vi.mock("./gateway-client.js",()=>({GatewayClient:class{connected=!1;request=vi.fn();handlers=new Map;constructor(){gatewayMock.instances.push(this)}get isConnected(){return this.connected}on(e,t){const a=this.handlers.get(e)??[];return a.push(t),this.handlers.set(e,a),this}async start(){}stop(){this.connected=!1}emit(e,t){for(const a of this.handlers.get(e)??[])a(t)}}}));const SESSION_KEY="agent:main:s1",TOOL_CALL_ID="call_00_DWmRWzffCw61OKMxB4hG0823";describe("canvas render_ui across plugin re-register (singleton ACPServer)",()=>{afterEach(()=>{vi.useRealTimers()}),it("survives a re-register: the singleton server is reused, so the session table is preserved",async()=>{const e=new CanvasStateManager;e.setRelayClient(mockRelay());const t=new ACPServer(makeConfig(),makeLogger(),createTestSessionMutationCoordinator().coordinator,void 0,vi.fn(),"inst_test");t.core.agentMapping=new Map([["main",{id:"main",instanceId:"inst_test",name:"main"}]]),t.setPendingToolCallSessionKey(SESSION_KEY),expect(t.getSessionIdForToolCall(TOOL_CALL_ID)).toBeTruthy();const a=createRenderUiHandler(makeServerOpts(e,t)),s=await a(TOOL_CALL_ID,renderParams());expect(s.success).toBe(!0),expect(t.getSessionIdForToolCall(TOOL_CALL_ID)).toBeTruthy();const n=createRenderUiHandler(makeServerOpts(e,t)),r=await n(TOOL_CALL_ID,renderParams());expect(r.success).toBe(!0),expect(r.surfaceId).toBe("world-cup-groups")}),it("cold start: render_ui recovers once agent-sync lands the session (no fixed-window giveup)",async()=>{const e=new CanvasStateManager;e.setRelayClient(mockRelay());let t=!1;const a=vi.fn(()=>t?"session-after-sync":void 0),s=vi.fn(async()=>{t=!0}),n={stateManager:e,logger:makeLogger(),writeObs:vi.fn(),getActiveSessionId:a,getGatewaySessionKey:()=>{},waitForAgentSync:s},r=createRenderUiHandler(n);vi.useFakeTimers();const o=r(TOOL_CALL_ID,renderParams());await vi.advanceTimersByTimeAsync(1300);const i=await o;expect(s).toHaveBeenCalled(),expect(i.success).toBe(!0),expect(i.surfaceId).toBe("world-cup-groups")}),it("last resort: still returns a RETRYABLE 'not ready' when the session never binds",async()=>{const e=vi.fn(()=>{}),t=vi.fn(async()=>{}),a={stateManager:new CanvasStateManager,logger:makeLogger(),writeObs:vi.fn(),getActiveSessionId:e,getGatewaySessionKey:()=>{},waitForAgentSync:t},s=createRenderUiHandler(a);vi.useFakeTimers();const n=s(TOOL_CALL_ID,renderParams());await vi.advanceTimersByTimeAsync(1300);const r=await n;expect(t).toHaveBeenCalledTimes(1),expect(r.success).toBe(!1),expect(r.retryable).toBe(!0),expect(String(r.error)).toMatch(/not ready/i)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e,conversationIdFromMessage=e=>{if("string"==typeof e.conversationId&&e.conversationId)return e.conversationId;const n=e.content??{};return"string"==typeof n.conversation_id&&n.conversation_id?n.conversation_id:"string"==typeof n.channel_id&&n.channel_id?n.channel_id:""};export const createImInboundHandler=e=>{const{runtime:n,pluginConfig:s,client:t,localServer:o,promptRunStore:a,
|
|
1
|
+
import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e,conversationIdFromMessage=e=>{if("string"==typeof e.conversationId&&e.conversationId)return e.conversationId;const n=e.content??{};return"string"==typeof n.conversation_id&&n.conversation_id?n.conversation_id:"string"==typeof n.channel_id&&n.channel_id?n.channel_id:""};export const createImInboundHandler=e=>{const{runtime:n,pluginConfig:s,client:t,localServer:o,promptRunStore:a,sessionMutationCoordinator:i,logger:r,writeObs:c,principalIdOverride:d,promptTimeoutMs:p,dispatch:l=dispatchImInboundPrompt}=e,m=new Map;return{onChatMessage:async e=>{const g=extractChatPrompt(e,{source:"chat_message",localAgentId:o.getLocalAgentId()});if(!g)return;if(!g.channelId||!g.messageId)return void r.warn?.(`[beeos-claw] chat_message missing channelId/messageId (sessionKey=${g.sessionKey}) — drop`);const h=promptRunKey(g.channelId,g.messageId);if(m.has(h))return void r.info?.(`[beeos-claw] duplicate chat_message replay dropped (message_id=${g.messageId}, channelId=${g.channelId})`);if(m.size>=256){const e=m.keys().next().value;void 0!==e&&m.delete(e)}m.set(h,!0);const I=d||o.getDefaultPlatformAgent()?.instanceId||"";if(!I)return void r.warn?.(`[beeos-claw] chat_message dropped (no principalId resolved, sessionKey=${g.sessionKey})`);const _=(e.content??{}).metadata??void 0,u="string"==typeof _?.delivery_principal?_.delivery_principal:"";if(u&&u!==I)return void r.info?.(`[beeos-claw] chat_message skipped (delivery_principal=${u}, selfPrincipalId=${I}, message_id=${g.messageId})`);o.ensureImInboundSession(g.sessionKey,g.channelId,g.messageId);const y=resolveOrigin(_?.protocol);r.debug?.(`[beeos-claw] chat_message dispatch (sessionKey=${g.sessionKey}, channelId=${g.channelId}, origin=${y})`);const f=e=>{c({component:"plugin",domain:"im_dispatch",name:`im.dispatch.${e.type}`,severity:"dispatch_error"===e.type?"warn":"info",payload:e})};try{await l({runtime:n,pluginConfig:s,prompt:{channelId:g.channelId,inboundMessageId:g.messageId,selfPrincipalId:I,dispatchInput:{message:g.message,attachments:g.files},sessionKey:g.sessionKey},client:t,logger:r,promptRunStore:a,sessionMutationCoordinator:i,abortTransport:o,origin:y,promptTimeoutMs:p,onLifecycleEvent:f})}catch(e){r.error?.(`[beeos-claw] chat_message dispatch failed channelId=${g.channelId} in_reply_to=${g.messageId} error_code=runtime_dispatch_failed`)}},onChatCancel:async e=>{const n=e.content??{},s="string"==typeof n.target_message_id?n.target_message_id:"",t="string"==typeof n.reason?n.reason:"unknown";if(!s)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no target_message_id, reason=${t})`);const o=conversationIdFromMessage(e);if(!o)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no conversation identity, target_message_id=${s}, reason=${t})`);const i=`chat_cancel:${t}`,c=await a.abort(promptRunKey(o,s),i);c.ok?r.info?.(`[beeos-claw] chat_cancel cancel acknowledged (target_message_id=${s}, requestId=${c.requestId}, channelId=${c.channelId}, reason=${t})`):r.info?.(`[beeos-claw] chat_cancel no-op (target_message_id=${s}, reason=${t}, why=${c.reason})`)}}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{createImInboundHandler as createImInboundHandlerImpl}from"./im-inbound-handler.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";const createImInboundHandler=e=>{const t=e.sessionWriteGate??new SessionWriteGate,a=e.sessionWriteReleaseCoordinator??new SessionWriteGateReleaseCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return createImInboundHandlerImpl({...e,sessionWriteGate:t,sessionWriteReleaseCoordinator:a})},fakeRuntime={channel:{reply:{dispatchReplyFromConfig:async()=>({counts:{tool:0,block:0,final:0}})}}},buildChatMessage=(e={})=>{const t=e.channelId??"chan_test";return{id:e.messageId??"msg_test",type:"chat_message",sender:e.sender??"user_caller",content:{text:e.text??"hello agent",message:e.text??"hello agent",metadata:{...e.protocol?{protocol:e.protocol}:{},...e.deliveryPrincipal?{delivery_principal:e.deliveryPrincipal}:{},context_id:e.contextId??"ctx_test"},channel_id:t}}},buildChatCancel=(e={})=>{const t={reason:e.reason??"canceled"};return e.omitTarget||(t.target_message_id=e.targetMessageId??"msg_target"),{id:"cancel_evt",type:"chat_cancel",...e.omitChannel?{}:{conversationId:e.channelId??"chan_test"},sender:"user_caller",content:t}},makeLogger=()=>{const e=[],t=[],a=[],n=[];return{warns:e,errors:t,infos:a,debugs:n,warn:t=>{e.push(t)},error:e=>{t.push(e)},info:e=>{a.push(e)},debug:e=>{n.push(e)}}},makeLocalServer=(e={})=>{const t=[];return{calls:t,getLocalAgentId:()=>e.localAgentId??"agent_local",getDefaultPlatformAgent:()=>e.noDefaultAgent?void 0:{instanceId:e.defaultInstanceId??"instance_self"},ensureImInboundSession:(e,a,n)=>{t.push({sessionKey:e,channelId:a,inboundMessageId:n})}}},fakeClient={};describe("createImInboundHandler",()=>{let e,t,a;beforeEach(()=>{e=new PromptRunStore,t=[],a=async e=>(t.push(e),!0)}),it("happy path: dispatches with correct prompt + origin=im + stamps cron-backfill session BEFORE dispatch",async()=>{const n=makeLocalServer(),r=makeLogger(),o=vi.fn(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:o,dispatch:a}),i=buildChatMessage({channelId:"chan_h1",messageId:"msg_h1"});await s.onChatMessage(i),expect(t).toHaveLength(1);const c=t[0];expect(c.prompt.channelId).toBe("chan_h1"),expect(c.prompt.inboundMessageId).toBe("msg_h1"),expect(c.prompt.selfPrincipalId).toBe("instance_self"),expect(c.prompt.dispatchInput.message).toBe("hello agent"),expect(c.origin).toBe("im"),expect(c.runtime).toBe(fakeRuntime),expect(c.client).toBe(fakeClient),expect(c.promptRunStore).toBe(e),expect(n.calls).toHaveLength(1);const l=n.calls[0];expect(l.channelId).toBe("chan_h1"),expect(l.inboundMessageId).toBe("msg_h1")}),it("skips chat_message addressed to another delivery_principal",async()=>{const n=makeLocalServer({defaultInstanceId:"agent_self"}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage({deliveryPrincipal:"agent_other"})),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.infos.some(e=>e.includes("delivery_principal=agent_other"))).toBe(!0)}),it.each([["a2a","a2a"],["mcp","mcp"],["invoke","invoke"],["im","im"],["webhook","webhook"],["slack-bridge","slack-bridge"],["arbitrary-future-tag","arbitrary-future-tag"]])("origin pass-through: metadata.protocol=%s → origin=%s",async(n,r)=>{const o=makeLocalServer(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:o,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await s.onChatMessage(buildChatMessage({protocol:n})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(r)}),it('origin fallback: missing metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage(buildChatMessage({})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: empty metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_empty",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:"",context_id:"ctx"},channel_id:"chan_empty"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: overlong metadata.protocol (>64 chars) → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="x".repeat(65);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: non-string metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_nonstr",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:42,context_id:"ctx"},channel_id:"chan_nonstr"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it("origin boundary: exactly 64-char metadata.protocol passes through",async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="y".repeat(64);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(o)}),it("drops chat_message when channelId is missing (cron-backfill marker not set)",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage({id:"msg_x",type:"chat_message",sender:"user",content:{text:"hi",message:"hi",metadata:{context_id:"ctx"}}}),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("missing channelId/messageId"))).toBe(!0)}),it("drops chat_message when no principalId resolvable",async()=>{const n=makeLocalServer({noDefaultAgent:!0}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage()),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("no principalId resolved"))).toBe(!0)}),it("principalIdOverride wins over getDefaultPlatformAgent",async()=>{const n=makeLocalServer({defaultInstanceId:"should_not_use"}),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),principalIdOverride:"pinned_principal",dispatch:a});await r.onChatMessage(buildChatMessage()),expect(t[0].prompt.selfPrincipalId).toBe("pinned_principal")}),it("dispatch error is swallowed + logged without crashing the handler",async()=>{const t=makeLocalServer(),a=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:a,writeObs:vi.fn(),dispatch:async()=>{throw new Error("runtime exploded")}});await expect(n.onChatMessage(buildChatMessage())).resolves.toBeUndefined(),expect(a.errors.some(e=>e.includes("dispatch failed")&&e.includes("error_code=runtime_dispatch_failed")&&!e.includes("runtime exploded"))).toBe(!0)}),it("chat_cancel: target_message_id maps to PromptRunStore.abort with reason prefix",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register(promptRunKey("chan_xyz","msg_cancel_me"),{requestId:"req_xyz",channelId:"chan_xyz",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({channelId:"chan_xyz",targetMessageId:"msg_cancel_me",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_xyz"))).toBe(!0)}),it("chat_cancel: bare target_message_id falls back when exactly one run matches",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register(promptRunKey("chan_legacy","msg_legacy_cancel"),{requestId:"req_legacy",channelId:"chan_legacy",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_legacy_cancel",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_legacy"))).toBe(!0)}),it("chat_cancel: bare target_message_id is no-op when multiple conversations match",()=>{const t=makeLogger(),n=new AbortController,r=new AbortController;e.register(promptRunKey("chan_a","msg_ambiguous_cancel"),{requestId:"req_a",channelId:"chan_a",abortController:n,startedAt:Date.now()}),e.register(promptRunKey("chan_b","msg_ambiguous_cancel"),{requestId:"req_b",channelId:"chan_b",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_ambiguous_cancel"})),expect(n.signal.aborted).toBe(!1),expect(r.signal.aborted).toBe(!1),expect(t.infos.some(e=>e.includes("why=ambiguous"))).toBe(!0)}),it("chat_cancel: drops when target_message_id missing",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitTarget:!0})),expect(t.warns.some(e=>e.includes("chat_cancel dropped"))).toBe(!0)}),it("chat_cancel: no-op when no matching run is registered",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({targetMessageId:"msg_unknown"})),expect(t.infos.some(e=>e.includes("chat_cancel no-op")&&e.includes("not_found"))).toBe(!0)}),it("replay dedup: same message_id delivered twice dispatches only once",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a}),s=buildChatMessage({channelId:"chan_dup",messageId:"msg_dup"});await o.onChatMessage(s),await o.onChatMessage(s),expect(t).toHaveLength(1),expect(r.infos.some(e=>e.includes("duplicate chat_message replay dropped")&&e.includes("msg_dup"))).toBe(!0)}),it("replay dedup: distinct message_ids both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_a"})),await n.onChatMessage(buildChatMessage({messageId:"msg_b"})),expect(t).toHaveLength(2)}),it("replay dedup: same message_id in different conversations both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({channelId:"chan_a",messageId:"msg_same"})),await n.onChatMessage(buildChatMessage({channelId:"chan_b",messageId:"msg_same"})),expect(t).toHaveLength(2)}),it("replay dedup: a replay after a FAILED dispatch is also dropped (stream already terminal-written by the dispatch catch path)",async()=>{const t=makeLogger();let a=0;const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:async()=>{throw a+=1,new Error("runtime exploded")}}),r=buildChatMessage({messageId:"msg_fail_replay"});await n.onChatMessage(r),await n.onChatMessage(r),expect(a).toBe(1),expect(t.infos.some(e=>e.includes("duplicate chat_message replay dropped"))).toBe(!0)}),it("replay dedup: LRU evicts oldest beyond 256 entries (insertion order)",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"}));for(let e=1;e<=256;e++)await n.onChatMessage(buildChatMessage({messageId:`msg_evict_${e}`}));await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"})),expect(t).toHaveLength(258)})});
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createImInboundHandler as createImInboundHandlerImpl}from"./im-inbound-handler.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";const createImInboundHandler=e=>{const t=new SessionWriteGate,a=e.sessionMutationCoordinator??new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return createImInboundHandlerImpl({...e,sessionMutationCoordinator:a})},fakeRuntime={channel:{reply:{dispatchReplyFromConfig:async()=>({counts:{tool:0,block:0,final:0}})}}},buildChatMessage=(e={})=>{const t=e.channelId??"chan_test";return{id:e.messageId??"msg_test",type:"chat_message",sender:e.sender??"user_caller",content:{text:e.text??"hello agent",message:e.text??"hello agent",metadata:{...e.protocol?{protocol:e.protocol}:{},...e.deliveryPrincipal?{delivery_principal:e.deliveryPrincipal}:{},context_id:e.contextId??"ctx_test"},channel_id:t}}},buildChatCancel=(e={})=>{const t={reason:e.reason??"canceled"};return e.omitTarget||(t.target_message_id=e.targetMessageId??"msg_target"),{id:"cancel_evt",type:"chat_cancel",...e.omitChannel?{}:{conversationId:e.channelId??"chan_test"},sender:"user_caller",content:t}},makeLogger=()=>{const e=[],t=[],a=[],n=[];return{warns:e,errors:t,infos:a,debugs:n,warn:t=>{e.push(t)},error:e=>{t.push(e)},info:e=>{a.push(e)},debug:e=>{n.push(e)}}},makeLocalServer=(e={})=>{const t=[];return{calls:t,getLocalAgentId:()=>e.localAgentId??"agent_local",getDefaultPlatformAgent:()=>e.noDefaultAgent?void 0:{instanceId:e.defaultInstanceId??"instance_self"},ensureImInboundSession:(e,a,n)=>{t.push({sessionKey:e,channelId:a,inboundMessageId:n})},chatAbort:async()=>{}}},fakeClient={};describe("createImInboundHandler",()=>{let e,t,a;beforeEach(()=>{e=new PromptRunStore,t=[],a=async e=>(t.push(e),!0)}),it("happy path: dispatches with correct prompt + origin=im + stamps cron-backfill session BEFORE dispatch",async()=>{const n=makeLocalServer(),o=makeLogger(),r=vi.fn(),i=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:o,writeObs:r,dispatch:a}),s=buildChatMessage({channelId:"chan_h1",messageId:"msg_h1"});await i.onChatMessage(s),expect(t).toHaveLength(1);const c=t[0];expect(c.prompt.channelId).toBe("chan_h1"),expect(c.prompt.inboundMessageId).toBe("msg_h1"),expect(c.prompt.selfPrincipalId).toBe("instance_self"),expect(c.prompt.dispatchInput.message).toBe("hello agent"),expect(c.origin).toBe("im"),expect(c.runtime).toBe(fakeRuntime),expect(c.client).toBe(fakeClient),expect(c.promptRunStore).toBe(e),expect(n.calls).toHaveLength(1);const l=n.calls[0];expect(l.channelId).toBe("chan_h1"),expect(l.inboundMessageId).toBe("msg_h1")}),it("skips chat_message addressed to another delivery_principal",async()=>{const n=makeLocalServer({defaultInstanceId:"agent_self"}),o=makeLogger(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:o,writeObs:vi.fn(),dispatch:a});await r.onChatMessage(buildChatMessage({deliveryPrincipal:"agent_other"})),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(o.infos.some(e=>e.includes("delivery_principal=agent_other"))).toBe(!0)}),it.each([["a2a","a2a"],["mcp","mcp"],["invoke","invoke"],["im","im"],["webhook","webhook"],["slack-bridge","slack-bridge"],["arbitrary-future-tag","arbitrary-future-tag"]])("origin pass-through: metadata.protocol=%s → origin=%s",async(n,o)=>{const r=makeLocalServer(),i=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:r,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await i.onChatMessage(buildChatMessage({protocol:n})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(o)}),it('origin fallback: missing metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage({})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: empty metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await o.onChatMessage({id:"msg_empty",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:"",context_id:"ctx"},channel_id:"chan_empty"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: overlong metadata.protocol (>64 chars) → origin="im"',async()=>{const n=makeLocalServer(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),r="x".repeat(65);await o.onChatMessage(buildChatMessage({protocol:r})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: non-string metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await o.onChatMessage({id:"msg_nonstr",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:42,context_id:"ctx"},channel_id:"chan_nonstr"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it("origin boundary: exactly 64-char metadata.protocol passes through",async()=>{const n=makeLocalServer(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),r="y".repeat(64);await o.onChatMessage(buildChatMessage({protocol:r})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(r)}),it("drops chat_message when channelId is missing (cron-backfill marker not set)",async()=>{const n=makeLocalServer(),o=makeLogger(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:o,writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_x",type:"chat_message",sender:"user",content:{text:"hi",message:"hi",metadata:{context_id:"ctx"}}}),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(o.warns.some(e=>e.includes("missing channelId/messageId"))).toBe(!0)}),it("drops chat_message when no principalId resolvable",async()=>{const n=makeLocalServer({noDefaultAgent:!0}),o=makeLogger(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:o,writeObs:vi.fn(),dispatch:a});await r.onChatMessage(buildChatMessage()),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(o.warns.some(e=>e.includes("no principalId resolved"))).toBe(!0)}),it("principalIdOverride wins over getDefaultPlatformAgent",async()=>{const n=makeLocalServer({defaultInstanceId:"should_not_use"}),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),principalIdOverride:"pinned_principal",dispatch:a});await o.onChatMessage(buildChatMessage()),expect(t[0].prompt.selfPrincipalId).toBe("pinned_principal")}),it("dispatch error is swallowed + logged without crashing the handler",async()=>{const t=makeLocalServer(),a=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:a,writeObs:vi.fn(),dispatch:async()=>{throw new Error("runtime exploded")}});await expect(n.onChatMessage(buildChatMessage())).resolves.toBeUndefined(),expect(a.errors.some(e=>e.includes("dispatch failed")&&e.includes("error_code=runtime_dispatch_failed")&&!e.includes("runtime exploded"))).toBe(!0)}),it("chat_cancel: target_message_id maps to PromptRunStore.abort with reason prefix",async()=>{const t=makeLocalServer(),n=makeLogger(),o=vi.fn(async()=>({ok:!0}));e.register(promptRunKey("chan_xyz","msg_cancel_me"),{requestId:"req_xyz",channelId:"chan_xyz",requestCancel:o,startedAt:Date.now()});const r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a});await r.onChatCancel(buildChatCancel({channelId:"chan_xyz",targetMessageId:"msg_cancel_me",reason:"timeout"})),expect(o).toHaveBeenCalledWith("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("cancel acknowledged")&&e.includes("req_xyz"))).toBe(!0)}),it("chat_cancel: drops when conversation identity is missing",async()=>{const t=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a});await n.onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_without_conversation"})),expect(e.size()).toBe(0),expect(t.warns.some(e=>e.includes("no conversation identity"))).toBe(!0)}),it("chat_cancel: drops when target_message_id missing",async()=>{const t=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a});await n.onChatCancel(buildChatCancel({omitTarget:!0})),expect(t.warns.some(e=>e.includes("chat_cancel dropped"))).toBe(!0)}),it("chat_cancel: no-op when no matching run is registered",async()=>{const t=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a});await n.onChatCancel(buildChatCancel({targetMessageId:"msg_unknown"})),expect(t.infos.some(e=>e.includes("chat_cancel no-op")&&e.includes("not_found"))).toBe(!0)}),it("replay dedup: same message_id delivered twice dispatches only once",async()=>{const n=makeLocalServer(),o=makeLogger(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:o,writeObs:vi.fn(),dispatch:a}),i=buildChatMessage({channelId:"chan_dup",messageId:"msg_dup"});await r.onChatMessage(i),await r.onChatMessage(i),expect(t).toHaveLength(1),expect(o.infos.some(e=>e.includes("duplicate chat_message replay dropped")&&e.includes("msg_dup"))).toBe(!0)}),it("replay dedup: distinct message_ids both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_a"})),await n.onChatMessage(buildChatMessage({messageId:"msg_b"})),expect(t).toHaveLength(2)}),it("replay dedup: same message_id in different conversations both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({channelId:"chan_a",messageId:"msg_same"})),await n.onChatMessage(buildChatMessage({channelId:"chan_b",messageId:"msg_same"})),expect(t).toHaveLength(2)}),it("replay dedup: a replay after a FAILED dispatch is also dropped (stream already terminal-written by the dispatch catch path)",async()=>{const t=makeLogger();let a=0;const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:async()=>{throw a+=1,new Error("runtime exploded")}}),o=buildChatMessage({messageId:"msg_fail_replay"});await n.onChatMessage(o),await n.onChatMessage(o),expect(a).toBe(1),expect(t.infos.some(e=>e.includes("duplicate chat_message replay dropped"))).toBe(!0)}),it("replay dedup: LRU evicts oldest beyond 256 entries (insertion order)",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"}));for(let e=1;e<=256;e++)await n.onChatMessage(buildChatMessage({messageId:`msg_evict_${e}`}));await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"})),expect(t).toHaveLength(258)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{mkdirSync,mkdtempSync,rmSync,unlinkSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,describe,expect,it}from"vitest";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{
|
|
1
|
+
import{mkdirSync,mkdtempSync,rmSync,unlinkSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";import{createImDispatchFixture,createRuntime,deferred,TestMessageStream}from"./test-fixtures/im-runtime.fixture.js";const SESSION_LOCK_DIAGNOSTIC=["Agent failed before reply: session file locked (timeout 120000ms):","pid=357 /home/node/.openclaw/agents/main/sessions/channel-test.jsonl.lock","gatewayToken=super-secret-token"].join(" "),asOutcome=async e=>{try{return{value:await e}}catch(e){return{error:e}}};describe("dispatchImInboundPrompt Message Service session coordination",()=>{const e=[];afterEach(()=>{vi.restoreAllMocks();for(const t of e.splice(0))rmSync(t,{recursive:!0,force:!0})}),it("does not invoke the runtime when cancel arrives before the mutation token",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),s=createImDispatchFixture({sessionWriteGate:e,sessionMutationCoordinator:t}),n=deferred();vi.spyOn(TestMessageStream.prototype,"opened").mockImplementationOnce(async()=>(await n.promise,{id:"test-envelope",state:"streaming"}));const o=vi.fn(),a=createRuntime(async()=>(o(),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(s.args(a));await vi.waitFor(()=>expect(s.client.streams).toHaveLength(1)),await s.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),n.resolve(),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(o).not.toHaveBeenCalled(),expect(s.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(e.snapshot("agent:main:im:channel-test").active).toBeUndefined(),expect(s.promptRunStore.size()).toBe(0)}),it("releases the cancelled session before a follow-up Message Service turn starts",async()=>{const e=createImDispatchFixture(),t=deferred(),s=deferred(),n=createRuntime(async({abortSignal:e,dispatcher:n})=>(t.resolve(),await new Promise((t,n)=>{e.addEventListener("abort",()=>{s.promise.then(()=>{n(new Error(String(e.reason)))})},{once:!0})}),n.sendFinalReply({text:"unreachable"}),{counts:{tool:0,block:0,final:1}})),o=dispatchImInboundPrompt(e.args(n));await t.promise,await e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),s.resolve(),await expect(o).rejects.toThrow(/chat_cancel:user_request/),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"});const a=createImDispatchFixture(),r=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"follow-up completed"}),{counts:{tool:0,block:0,final:1}})),i=await dispatchImInboundPrompt(a.args(r,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-cancel",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"follow-up"}}}));expect(i).toBe(!0),expect(a.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"follow-up completed"})}),it("redacts raw OpenClaw session-lock diagnostics from the Message Service reply",async()=>{const e=createImDispatchFixture(),t=createRuntime(async()=>{throw new Error(SESSION_LOCK_DIAGNOSTIC)});await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(SESSION_LOCK_DIAGNOSTIC);const s=classifyAgentRuntimeFailure({raw:SESSION_LOCK_DIAGNOSTIC,source:"live_gateway"}).publicMessage,n=e.client.streams[0]?.terminal;expect(n).toMatchObject({state:"failed",stopReason:"error",body:s}),expect(n?.body).not.toContain(".jsonl.lock"),expect(n?.body).not.toContain("super-secret-token"),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"session_file_locked"})}),it("terminalizes a gateway-injected lock final without ever writing the diagnostic body",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:SESSION_LOCK_DIAGNOSTIC}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(/session file locked/i),expect(e.client.streams[0]?.terminal).toMatchObject({state:"failed",stopReason:"error",body:"The previous run was interrupted before it completed."}),expect(e.client.streams[0]?.body).not.toContain(".jsonl.lock"),expect(e.client.streams[0]?.body).not.toContain("super-secret-token")}),it("releases the gate immediately when runtime dispatch throws synchronously",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{observationTimeoutMs:1}),s=createImDispatchFixture({sessionWriteGate:e,sessionMutationCoordinator:t}),n={channel:{reply:{dispatchReplyFromConfig:()=>{throw new Error("runtime rejected before writer start")}}}};await expect(dispatchImInboundPrompt(s.args(n))).rejects.toThrow("runtime rejected before writer start"),expect(e.snapshot("agent:main:im:channel-test").active).toBeUndefined();const o=createImDispatchFixture({sessionWriteGate:e,sessionMutationCoordinator:t}),a=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"accepted"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(o.args(a))).resolves.toBe(!0)}),it("returns a busy reply without invoking the runtime for a concurrent same-session envelope",async()=>{const e=new SessionWriteGate,t={sessionWriteGate:e,sessionMutationCoordinator:new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{})},s=new Set,n=deferred(),o=deferred(),a=[],r=createRuntime(async({invocation:e,abortSignal:t})=>{const r=e.ctx.SessionKey;if(a.push(r),s.has(r))throw new Error(SESSION_LOCK_DIAGNOSTIC);return s.add(r),n.resolve(),await new Promise((e,n)=>{t.addEventListener("abort",()=>{o.promise.then(()=>{s.delete(r),n(new Error(String(t.reason)))})},{once:!0})}),{counts:{tool:0,block:0,final:0}}}),i=createImDispatchFixture(t),c=dispatchImInboundPrompt(i.args(r));await n.promise;const p=createImDispatchFixture(t),l=asOutcome(dispatchImInboundPrompt(p.args(r,{prompt:{channelId:"channel-test",inboundMessageId:"message-concurrent",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"concurrent prompt"}}}))),m=await l;await i.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),o.resolve(),await asOutcome(c),expect(m).toEqual({value:!1}),expect(a).toEqual(["agent:main:im:channel-test"]),expect(p.client.streams[0]?.terminal).toMatchObject({state:"failed",body:expect.stringMatching(/still stopping/i)})}),it("keeps a non-contended Message Service stream on the normal completion path",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply?.({text:"normal"}),e.sendFinalReply({text:"normal completion"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).resolves.toBe(!0),expect(e.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"normal completion"}),expect(e.events.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"])}),it("keeps the shared gate closed until the cancelled JSONL writer releases its lock",async()=>{const t="agent:main:im:locked-conversation",s=mkdtempSync(join(tmpdir(),"beeos-im-lock-"));e.push(s);const n=join(s,"agents","main","sessions");mkdirSync(n,{recursive:!0});const o=join(n,"locked-session.jsonl"),a=`${o}.lock`;writeFileSync(o,""),writeFileSync(join(n,"sessions.json"),JSON.stringify({[t]:{sessionId:"locked-session",sessionFile:o}}));const r=new SessionWriteGate,i=new OpenClawSessionWriteQuiescence({},{stateDir:s,pollIntervalMs:5,quietConfirmationMs:5}),c={sessionWriteGate:r,sessionMutationCoordinator:new SessionMutationCoordinator(r,i,{},{observationTimeoutMs:100})},p=deferred();let l=0;const m=createRuntime(async({abortSignal:e,dispatcher:t})=>(l+=1,1===l&&(p.resolve(),await new Promise((t,s)=>{e.addEventListener("abort",()=>{s(new Error(String(e.reason)))},{once:!0})})),t.sendFinalReply({text:"after lock released"}),{counts:{tool:0,block:0,final:1}})),d=createImDispatchFixture(c),u=asOutcome(dispatchImInboundPrompt(d.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-before-cancel",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"start long run"}}})));await p.promise,writeFileSync(a,"331"),await d.promptRunStore.abort(promptRunKey("channel-test","message-before-cancel"),"chat_cancel:user_request");const h=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(h.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-while-locked",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"must not enter runtime"}}}))).resolves.toBe(!1),expect(l).toBe(1),expect(d.client.streams[0]?.terminal).toBeUndefined(),unlinkSync(a),expect(await u).toHaveProperty("error");const w=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(w.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-unlock",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"now safe"}}}))).resolves.toBe(!0),expect(l).toBe(2)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.js";const normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,r,a)=>`${e}_${createHash("sha256").update(`${n}:${r}:${a}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:r,client:a,logger:t,promptRunStore:i,
|
|
1
|
+
import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{SESSION_RECOVERY_REQUIRED_CODE,SESSION_RECOVERY_REQUIRED_MESSAGE}from"./runtime/session-mutation-coordinator.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.js";const normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,r,a)=>`${e}_${createHash("sha256").update(`${n}:${r}:${a}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:r,client:a,logger:t,promptRunStore:i,sessionMutationCoordinator:s,abortTransport:o}=e;if(!hasRuntimeReplyApi(n))return t.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),!1;const d=`im_req_${randomUUID().replace(/-/g,"")}`,c=Date.now(),l=e.origin??"im",u={protocol:"im",requestId:d,sessionId:r.sessionKey??"agent:main",sourceMessageId:r.inboundMessageId,chatId:r.channelId,origin:l},m=normalizePromptTimeoutMs(e.promptTimeoutMs),p=promptRunKey(r.channelId,r.inboundMessageId),I=r.sessionKey??"agent:main",h=new AbortController;let g,y,_;const f=new Promise((e,n)=>{_=n}),E=async e=>{if(y??=e,!g)return h.abort(e),{ok:!0};const n=await s.requestCancel(g,o,()=>h.abort(e));return n.ok?n.terminalObservation.then(e=>{e.ok||_(new Error(SESSION_RECOVERY_REQUIRED_CODE))}):_(new Error(SESSION_RECOVERY_REQUIRED_CODE)),{ok:n.ok}};if(!i.register(p,{requestId:d,channelId:r.channelId,requestCancel:E,startedAt:c,sessionKey:I}))return t.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${r.inboundMessageId} requestId=${d} channel=${r.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const b=a.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("asst",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const a="terminal"===n?t.warn:t.debug;a?.(`[im] runtime stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId});let $,R=!1;try{R=!0===(await b.opened()).idempotent}catch(e){t.debug?.(`[im] runtime reply stream open probe failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(R)return t.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),i.unregister(p),!1;const w=new SerializedCallbackQueue,M=createImReplySink({stream:b,openReasoningStream:()=>($=a.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("rsn",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const a="terminal"===n?t.warn:t.debug;a?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId}),$),abortSignal:h.signal,callbackQueue:w,logger:t,requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,origin:l,onAgentRunStart:e=>{s.bindRun(g,e)}}),S=interceptAgentRuntimeFailures(M,"im_runtime",e=>t.warn?.(`[im] runtime failure reply suppressed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} error_code=${e.errorCode}`)),v=S.sink,q=async(e,n)=>{if(!$)return!1;try{return"cancel"===e?await $.cancel({stopReason:"user_stop"}):"fail"===e?await $.fail(n?{stopReason:n}:void 0):await $.finalize({stopReason:"end_turn"}),!1}catch(e){return t.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`),!0}};let C,k,O=!1;const D=async e=>{v.dispatcher.markComplete(e);try{await v.dispatcher.waitForIdle()}catch{}},A=()=>{if(!h.signal.aborted)return;const e=h.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))};let U,T;const F=async e=>{if(!g)throw new Error("session_mutation_not_acquired");const n=await s.converge(g,e);return n.ok?(U=n.permit,!0):(t.error?.(`[im] session mutation recovery required requestId=${d} sessionKey=${I} reason=${n.recovery.reason}`),T={code:n.recovery.code,reason:n.recovery.reason,sessionKey:n.recovery.sessionKey,operationId:n.recovery.operationId,surface:"message_service",...n.recovery.terminalKind?{terminalKind:n.recovery.terminalKind}:{}},!1)};try{A();const a=s.tryStartInteractive({sessionKey:I,label:"im_chat_message"},"message_service");if(!a.ok){try{await b.fail({body:SESSION_BUSY_PUBLIC_MESSAGE,stopReason:"error"})}catch(e){t.warn?.(`[im] session busy terminal write failed channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return t.warn?.(`[im] session write gate busy sessionKey=${I} channel=${r.channelId} in_reply_to=${r.inboundMessageId} active_operation=${a.activeOperationId??"unknown"}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"session_busy",cause:"runtime_error"}),!1}g=a.token,A(),t.info?.(`[im] runtime dispatching requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} origin=${l}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId});const o=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:r.dispatchInput,replyRoute:u,replySinks:{im:v},onDispatchInvoked:()=>{O=!0}});void 0!==m&&(C=setTimeout(()=>{k=new Error(`IM runtime dispatch timed out after ${m}ms`),t.warn?.(`[im] runtime dispatch timeout requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} timeout_ms=${m}`),E(k)},m));const h=Promise.race([o,f]),y=await h.finally(()=>{C&&(clearTimeout(C),C=void 0)}),_=S.detectedFailure();if(_)throw new Error(_.diagnosticMessage);if(A(),await D("success"),A(),!i.claimTerminal(p,"completion")){if(A(),"cancelling"===i.phase(p))throw new Error("cancellation in progress");throw new Error("terminal outcome already claimed")}if(!await F("completed"))return await q("fail"),await b.fail({body:SESSION_RECOVERY_REQUIRED_MESSAGE,stopReason:"error"}).catch(()=>{}),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:SESSION_RECOVERY_REQUIRED_CODE,cause:"runtime_error",...T?{recovery:T}:{}}),!1;const $=await q("complete");let R=!1;try{await b.finalize({stopReason:"end_turn"})}catch(e){R=!0,t.warn?.(`[im] runtime reply stream finalize failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return t.info?.(`[im] runtime dispatch completed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} counts=${JSON.stringify(y.counts)} elapsed_ms=${Date.now()-c}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,counts:y.counts,...R?{terminalWriteFailed:!0}:{},...$?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await D("failure");const a=h.signal.aborted,s=a?h.signal.reason:void 0,o="cancelling"===i.phase(p),c=void 0!==k,l=s??y,u=!c&&"string"==typeof l&&l.startsWith("chat_cancel");let m="runtime_error";c?m="timeout":(a||o)&&(m="abort");const I=o||u||c||a?"cancellation":"completion";if(!i.claimTerminal(p,I))throw t.warn?.(`[im] terminal write skipped because another outcome already committed requestId=${d} channel=${r.channelId}`),n;const _=u?"cancelled":c?"timeout":"error";if(g&&!await F(_))throw await q("fail"),await b.fail({body:SESSION_RECOVERY_REQUIRED_MESSAGE,stopReason:"error"}).catch(()=>{}),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:SESSION_RECOVERY_REQUIRED_CODE,cause:m,...T?{recovery:T}:{}}),n;const f=S.detectedFailure()??classifyAgentRuntimeFailure({raw:n,source:"im_runtime",runtimeKind:"openclaw"});u?await q("cancel"):c?await q("fail","timeout"):await q("fail");try{u?await b.cancel({stopReason:"user_stop"}):await b.fail({body:f.publicMessage,stopReason:messageStopReasonForRuntimeFailure(f)})}catch(e){t.warn?.(`[im] runtime stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}throw t.warn?.(`[im] runtime dispatch failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} cause=${m} dispatchInvoked=${O} error_code=${f.errorCode}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:f.errorCode,cause:m}),n}finally{i.unregister(p),O?U&&s.release(U):s.releaseBeforeRuntime(g)}};
|