beeos-claw 0.5.59 → 0.5.61
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/bridge-core-cron-timeout.test.js +1 -1
- package/dist/acp-gateway/bridge-core-session-mutation-lifecycle.test.js +1 -1
- package/dist/acp-server-chat-abort.test.js +1 -0
- package/dist/acp-server.js +1 -1
- package/dist/gateway-client-chat-abort.test.js +1 -0
- package/dist/gateway-client.js +1 -1
- package/dist/im-inbound-handler.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/agent-runtime-failure-classifier.js +1 -1
- package/dist/runtime/agent-runtime-failure-classifier.test.js +1 -1
- package/dist/runtime/im-reply-sink.js +1 -1
- package/dist/runtime/runtime-restart-scheduler.js +1 -0
- package/dist/runtime/runtime-restart-scheduler.test.js +1 -0
- package/dist/runtime/session-mutation-coordinator.js +1 -1
- package/dist/runtime/session-mutation-coordinator.test.js +1 -1
- package/dist/test-fixtures/im-runtime.fixture.js +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),o={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,agentCancel:vi.fn(),chatAbort:vi.fn().mockResolvedValue(
|
|
1
|
+
import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),o={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,agentCancel:vi.fn(),chatAbort:vi.fn().mockResolvedValue({aborted:!0,runIds:[]}),request:vi.fn().mockResolvedValue({})};return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:o,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set}),state:t,transport:o,gateway:n,logger:e}}describe("schedulePromptTimeout — adopted background (cron) runs",()=>{beforeEach(()=>{vi.useFakeTimers()}),afterEach(()=>{vi.useRealTimers()}),it("fires for a currentRunId-only session: awaited chat.abort + timeout terminal",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:cron-job-1");t.currentRunId="run-bg-1",t.cronJobId="job-bg-1",t.promptDone=!1,e.bridge.schedulePromptTimeout(t),vi.advanceTimersByTime(300001),await Promise.resolve(),await Promise.resolve(),expect(e.gateway.chatAbort).toHaveBeenCalledWith(t.gatewaySessionKey,"run-bg-1"),expect(t.currentRunId).toBeUndefined(),expect(t.promptDone).toBeUndefined(),expect(e.transport.sendSessionUpdate).toHaveBeenCalledWith(t.sessionId,expect.objectContaining({sessionUpdate:"stream_complete",stopReason:"timeout",errorCode:"gateway_timeout"}),expect.objectContaining({turnId:"run-bg-1"})),expect(e.transport.sendError).not.toHaveBeenCalled()}),it("stays a no-op for a fully idle session (no pendingPromptId, no currentRunId)",()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:idle");e.bridge.schedulePromptTimeout(t),vi.advanceTimersByTime(186e4),expect(e.gateway.chatAbort).not.toHaveBeenCalled(),expect(e.transport.sendSessionUpdate).not.toHaveBeenCalled(),expect(t.promptDone).not.toBe(!0)}),it("does not fire after normal completion cleared the timer (no false kill)",()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:cron-job-2");t.currentRunId="run-bg-2",t.cronJobId="job-bg-2",t.promptDone=!1,e.bridge.schedulePromptTimeout(t),e.bridge.endRun(t,{kind:"complete",stopReason:"end_turn"}),e.gateway.chatAbort.mockClear(),vi.advanceTimersByTime(72e5),expect(e.gateway.chatAbort).not.toHaveBeenCalled()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{afterEach,describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";const deferred=()=>{let e;return{promise:new Promise(t=>{e=t}),resolve:e}},makeBridge=(e,t)=>{const n={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},o=new AcpGatewaySessionState(n),s={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(()=>{if("transport"===t)throw new Error("transport failed");return!0}),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a={isConnected:!0,chatAbort:vi.fn().mockResolvedValue(
|
|
1
|
+
import{afterEach,describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";const deferred=()=>{let e;return{promise:new Promise(t=>{e=t}),resolve:e}},makeBridge=(e,t)=>{const n={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},o=new AcpGatewaySessionState(n),s={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(()=>{if("transport"===t)throw new Error("transport failed");return!0}),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a={isConnected:!0,chatAbort:vi.fn().mockResolvedValue({aborted:!0,runIds:[]}),sessionsDelete:vi.fn().mockResolvedValue(void 0),request:vi.fn().mockResolvedValue({})},r=createTestSessionMutationCoordinator({},e),i=new AcpGatewayBridgeCore({sessionMutationCoordinator:r.coordinator,state:o,transport:s,config:{gateway:{agentId:"main"},promptTimeoutMs:10,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5},logger:n,writeObs:vi.fn(),getGateway:()=>a,instanceId:"test-instance"});return"hook"===t&&(i.onRunComplete=()=>{throw new Error("hook failed")}),{bridge:i,state:o,transport:s,gateway:a,mutation:r}},prepareRun=async(e,t)=>{const n=e.bridge.ensureSessionForKey(t),o=e.mutation.coordinator.tryStartInteractive({sessionKey:t},"acp");if(!o.ok)throw new Error("expected mutation lease");return n.writeGateToken=o.token,n.pendingPromptId="prompt-1",n.currentRunId="run-1",n.promptDone=!1,e.mutation.coordinator.bindRun(o.token,"run-1"),n};describe("AcpGatewayBridgeCore session mutation lifecycle",()=>{afterEach(()=>vi.useRealTimers()),it("clears timeout and idle timers before waiting for terminal quiescence",async()=>{vi.useFakeTimers();const e=deferred(),t=makeBridge({waitForQuiescence:async()=>(await e.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})}),n=await prepareRun(t,"agent:main:timer-race");t.bridge.schedulePromptTimeout(n),n.streamIdleTimer=setTimeout(()=>{},10);const o=t.bridge.endRun(n,{kind:"complete",stopReason:"end_turn"});expect(n.promptTimeoutTimer).toBeUndefined(),expect(n.streamIdleTimer).toBeUndefined(),await vi.advanceTimersByTimeAsync(20),expect(t.gateway.chatAbort).not.toHaveBeenCalled(),e.resolve(),await expect(o).resolves.toBe(!0)}),it("uses the timeout override as the coordinator terminal kind",async()=>{const e=deferred(),t=makeBridge({waitForQuiescence:async()=>(await e.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})}),n=await prepareRun(t,"agent:main:timeout-kind");n.mutationTerminalOverride="timeout";const o=t.bridge.endRun(n,{kind:"complete",stopReason:"cancelled"});await Promise.resolve(),expect(t.mutation.coordinator.snapshot(n.gatewaySessionKey)?.terminalKind).toBe("timeout"),e.resolve(),await expect(o).resolves.toBe(!0)}),it.each(["transport","hook"])("cleans up and releases the permit when %s publication throws",async e=>{const t=makeBridge(void 0,e),n=`agent:main:${e}-failure`,o=await prepareRun(t,n),s=vi.fn();t.bridge.onRunEnded=s,await expect(t.bridge.endRun(o,{kind:"complete",stopReason:"end_turn"})).rejects.toThrow(`${e} failed`),expect(o.currentRunId).toBeUndefined(),expect(o.pendingPromptId).toBeUndefined(),expect(t.mutation.gate.snapshot(n).active).toBeUndefined(),expect(s).toHaveBeenCalledTimes(1)}),it("does not release the Message Service owner when an ACP observer ends first",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:observer-first"),n=e.mutation.coordinator.tryStartInteractive({sessionKey:t.gatewaySessionKey},"message_service");if(!n.ok)throw new Error("expected Message Service owner");e.mutation.coordinator.bindRun(n.token,"run-shared");const o=e.bridge.adoptBackgroundMutation(t,"run-shared");expect(o).toMatchObject({ok:!0,ownership:"observer"}),expect(t.writeGateToken).toBeUndefined(),t.pendingPromptId="observer-prompt",t.currentRunId="run-shared",t.promptDone=!1,await expect(e.bridge.endRun(t,{kind:"complete",stopReason:"end_turn"})).resolves.toBe(!0),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)).toMatchObject({operationId:n.token.operationId,phase:"active",surface:"message_service"}),expect(e.mutation.gate.snapshot(t.gatewaySessionKey).active?.operationId).toBe(n.token.operationId)}),it("cancels the Message Service owner from an ACP observer session",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:observer-cancel"),n=e.mutation.coordinator.tryStartInteractive({sessionKey:t.gatewaySessionKey},"message_service");if(!n.ok)throw new Error("expected Message Service owner");e.mutation.coordinator.bindRun(n.token,"run-ms-owner");const o=e.bridge.adoptBackgroundMutation(t,"run-ms-owner");expect(o).toMatchObject({ok:!0,ownership:"observer"}),expect(t.writeGateToken).toBeUndefined(),t.currentRunId="run-ms-owner",t.promptDone=!1,await e.bridge.handleSessionCancel("cancel-observer",{sessionId:t.sessionId}),expect(e.gateway.chatAbort).toHaveBeenCalledWith(t.gatewaySessionKey,"run-ms-owner"),expect(e.transport.sendResult).toHaveBeenCalledWith("cancel-observer",{}),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)).toMatchObject({operationId:n.token.operationId,phase:"cancelling",cancelAcknowledged:!0}),expect(t.writeGateToken).toBeUndefined();const s=await e.mutation.coordinator.converge(n.token,"cancelled");s.ok&&e.mutation.coordinator.release(s.permit)}),it.each([{kind:"request",id:"cancel-no-owner"},{kind:"notification",id:void 0}])("treats connected session cancel with no owner as an idempotent $kind",async({id:e})=>{const t=makeBridge(),n=t.bridge.ensureSessionForKey("agent:main:cancel-no-owner");await t.bridge.handleSessionCancel(e,{sessionId:n.sessionId}),expect(t.gateway.chatAbort).not.toHaveBeenCalled(),expect(t.transport.sendError).not.toHaveBeenCalled(),expect(t.transport.sendAcpError).not.toHaveBeenCalled(),void 0===e?expect(t.transport.sendResult).not.toHaveBeenCalled():expect(t.transport.sendResult).toHaveBeenCalledWith(e,{})}),it.each(["currentRunId","internalOwner"])("rejects session/delete as busy for an active %s mutation",async e=>{const t=makeBridge(),n=t.bridge.ensureSessionForKey(`agent:main:delete-${e}`);"currentRunId"===e?(n.currentRunId="run-active",n.promptDone=!1):await t.mutation.coordinator.acquireInternal({sessionKey:n.gatewaySessionKey,label:"internal-test"}),await t.bridge.handleRequest({jsonrpc:"2.0",id:"delete-1",method:"session/delete",params:{sessionId:n.sessionId}}),expect(t.transport.sendError).toHaveBeenCalledWith("delete-1",expect.any(Number),"Agent is busy",expect.objectContaining({code:"session_busy",reason:"active_session_mutation"})),expect(t.gateway.sessionsDelete).not.toHaveBeenCalled(),expect(t.state.sessions.get(n.sessionId)).toBe(n)}),it("holds the session mutation lease across remote and local delete",async()=>{const e=deferred(),t=makeBridge(),n=t.bridge.ensureSessionForKey("agent:main:delete-interleaving");t.gateway.sessionsDelete.mockImplementationOnce(async()=>{await e.promise});const o=t.bridge.handleRequest({jsonrpc:"2.0",id:"delete-interleaving",method:"session/delete",params:{sessionId:n.sessionId}});await vi.waitFor(()=>expect(t.gateway.sessionsDelete).toHaveBeenCalledTimes(1));const s=t.mutation.coordinator.snapshot(n.gatewaySessionKey);expect(s).toMatchObject({attachmentPolicy:"exclusive",phase:"active"}),expect(t.mutation.coordinator.attachOrStartExternal({sessionKey:n.gatewaySessionKey},"message_service","run-during-delete")).toEqual({ok:!1,reason:"session_busy"}),expect(t.mutation.coordinator.snapshot(n.gatewaySessionKey)).toEqual(s),expect(t.state.sessions.get(n.sessionId)).toBe(n),await t.bridge.handleSessionCancel("cancel-during-delete",{sessionId:n.sessionId}),expect(t.gateway.chatAbort).not.toHaveBeenCalled(),expect(t.transport.sendError).toHaveBeenCalledWith("cancel-during-delete",expect.any(Number),"Agent is busy",expect.objectContaining({code:"session_busy",reason:"exclusive_session_mutation"})),expect(t.mutation.coordinator.snapshot(n.gatewaySessionKey)).toEqual(s),e.resolve(),await o,expect(t.state.sessions.has(n.sessionId)).toBe(!1),expect(t.mutation.gate.snapshot(n.gatewaySessionKey).active).toBeUndefined()}),it("releases the delete lease when the remote delete fails",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:delete-failure-release");e.gateway.sessionsDelete.mockRejectedValueOnce(new Error("delete rejected")),await e.bridge.handleRequest({jsonrpc:"2.0",id:"delete-failure-release",method:"session/delete",params:{sessionId:t.sessionId}}),expect(e.transport.sendResult).toHaveBeenCalledWith("delete-failure-release",{deleted:!1}),expect(e.state.sessions.get(t.sessionId)).toBe(t),expect(e.mutation.coordinator.tryStartInteractive({sessionKey:t.gatewaySessionKey},"message_service")).toMatchObject({ok:!0})}),it("returns recovery_required instead of success when chat.abort ACK fails",async()=>{const e=makeBridge(),t=await prepareRun(e,"agent:main:abort-ack-failed");e.gateway.chatAbort.mockRejectedValueOnce(new Error("abort rejected")),await e.bridge.handleSessionCancel("cancel-1",{sessionId:t.sessionId}),expect(e.transport.sendError).toHaveBeenCalledWith("cancel-1",expect.any(Number),expect.any(String),expect.objectContaining({code:"session_recovery_required"})),expect(e.transport.sendResult).not.toHaveBeenCalledWith("cancel-1",{}),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)?.phase).toBe("recovery_required")})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{ACPServer}from"./acp-server.js";describe("ACPServer chat.abort contract",()=>{it("passes the typed GatewayClient result through without discarding false",async()=>{const e=vi.fn().mockResolvedValue({aborted:!1,runIds:[]}),t=Object.create(ACPServer.prototype);t.gateway={isConnected:!0,chatAbort:e},await expect(t.chatAbort("agent:main","run-missed")).resolves.toEqual({aborted:!1,runIds:[]}),expect(e).toHaveBeenCalledWith("agent:main","run-missed")})});
|
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{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
|
+
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");return 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:async(e,t)=>{await 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"})}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{decodeChatAbortResult,GatewayClient}from"./gateway-client.js";describe("GatewayClient chat.abort contract",()=>{it("adapts OpenClaw's wire payload to typed aborted/runIds",async()=>{const e=vi.fn().mockResolvedValue({ok:!0,aborted:!0,runIds:["run-typed"]}),t=Object.create(GatewayClient.prototype);t.request=e,await expect(t.chatAbort("agent:main","run-typed")).resolves.toEqual({aborted:!0,runIds:["run-typed"]}),expect(e).toHaveBeenCalledWith("chat.abort",{sessionKey:"agent:main",runId:"run-typed"})}),it("preserves an explicit aborted=false response",()=>{expect(decodeChatAbortResult({ok:!0,aborted:!1,runIds:[]})).toEqual({aborted:!1,runIds:[]})}),it("rejects ambiguous chat.abort responses",()=>{expect(()=>decodeChatAbortResult({ok:!0})).toThrow(/boolean aborted/),expect(()=>decodeChatAbortResult({aborted:!0,runIds:[7]})).toThrow(/string\[\] runIds/)})});
|
package/dist/gateway-client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{WebSocket}from"ws";import{EventEmitter}from"node:events";import{randomUUID,createHash}from"node:crypto";import{loadOrCreateDeviceIdentity,buildDeviceAuthPayload,signDeviceAuth,ensureDevicePaired,warmupCryptoSign}from"./device-identity.js";const MIN_PROTOCOL_VERSION=3,MAX_PROTOCOL_VERSION=4,CONNECT_TIMEOUT_MS=3e4,HEARTBEAT_INTERVAL_MS=15e3,LIVENESS_TIMEOUT_MS=6e4,CHAT_HISTORY_PAGE_TIMEOUT_MS=1e4,AUTH_FAILURE_CLOSE_CODE=4001,EL_LAG_INTERVAL_MS=200,EL_LAG_THRESHOLD_MS=500,RECONNECT_NOTIFY_MIN_MS=6e4,RECONNECT_NOTIFY_MAX_MS=18e4,RECONNECT_NOTIFY_METHOD="server/reconnect";function wsUrlToHttpBase(e){try{const t=new URL(e),n="wss:"===t.protocol?"https:":"ws:"===t.protocol?"http:":null;return n?`${n}//${t.host}`:null}catch{return null}}const MANUAL_RECONNECT_SIGNAL="SIGUSR1";function registerReconnectSignal(e,t,n){detachReconnectSignal(e),e.handler=t;try{process.on("SIGUSR1",t),n?.info?.("[gateway] manual reconnect enabled signal=SIGUSR1")}catch{n?.warn?.("[gateway] manual reconnect signal not supported")}}function detachReconnectSignal(e){if(e.handler){try{process.off("SIGUSR1",e.handler)}catch{}e.handler=null}}export class GatewayClient extends EventEmitter{ws=null;config;pending=new Map;connected=!1;stopped=!1;authFailed=!1;connectPromise=null;reconnectTimer=null;reconnectAttempt=0;nextReconnectDelayHintMs=null;signalState={handler:null};deviceIdentity;heartbeatTimer=null;lastPongTs=0;_connectionSeq=0;_currentConnectionId=null;_lastReadyAt=0;reconnectNotifyTimer=null;constructor(e){super(),this.config=e,this.deviceIdentity=loadOrCreateDeviceIdentity();try{ensureDevicePaired(this.deviceIdentity)}catch(e){}Date.now();warmupCryptoSign(this.deviceIdentity)}async start(){this.stopped=!1,this.reconnectAttempt=0,registerReconnectSignal(this.signalState,()=>{this.stopped||(this.emit("manual-reconnect"),this.reconnectNow())}),await this.connect()}stop(){this.stopped=!0,this.stopHeartbeat(),this.clearReconnectNotify(),detachReconnectSignal(this.signalState),this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.close()}async connect(){if(this.connectPromise)return this.connectPromise;this.connectPromise=this._connect();try{await this.connectPromise}finally{this.connectPromise=null}}_connect(){return new Promise((e,t)=>{if(this.authFailed)return void t(new Error("Authentication failed — not retrying"));const n=Date.now();this.emit("transport",{event:"connect_start",url:this.config.url,attempt:this.reconnectAttempt});const s=new WebSocket(this.config.url);this.ws=s;let i=null;const o=setTimeout(()=>{this.emit("transport",{event:"connect_fail",reason:"timeout",durationMs:Date.now()-n}),s.close(),t(new Error("Gateway connection timeout"))},3e4),r=t=>{clearTimeout(o),this.connected=!0,this.reconnectAttempt=0,this.authFailed=!1,this._connectionSeq++,this._currentConnectionId=randomUUID(),this._lastReadyAt=Date.now(),this.startHeartbeat(),this.emit("transport",{event:"connect_ok",connectionSeq:this._connectionSeq,connectionId:this._currentConnectionId,durationMs:Date.now()-n}),this.emit("connected",{...t,connectionSeq:this._connectionSeq,connectionId:this._currentConnectionId}),e()},c=e=>{clearTimeout(o),this.authFailed=!0,this.emit("transport",{event:"connect_fail",reason:e,durationMs:Date.now()-n}),this.emit("auth-failed",{reason:e}),s.close(),t(new Error(e))};s.on("open",()=>{this.emit("transport",{event:"ws_open",durationMs:Date.now()-n})}),s.on("message",e=>{const a="string"==typeof e?e:e.toString("utf-8");if("pong"===a)return void(this.lastPongTs=Date.now());let h;this.emitMessageHash(a);try{h=JSON.parse(a)}catch{return}const l=h.method;l&&!h.id&&(this.isReconnectNotification(l)?this.scheduleReconnectNotify():this.reconnectNotifyTimer&&this.clearReconnectNotify(),this.emit("notification",l,h.params));const d=h.type;if("event"===d&&this.reconnectNotifyTimer&&this.clearReconnectNotify(),"event"===d){const e=h.event,t=h.payload;if("connect.challenge"===e){const e=Date.now();this.emit("transport",{event:"handshake.challenge_recv",durationMs:e-n});const s=t?.nonce;i=this._sendConnect(s),this.emit("transport",{event:"handshake.connect_sent",durationMs:Date.now()-n});let o=0;const r=setInterval(()=>{o++},200),c=Date.now(),a=setInterval(()=>{const e=Date.now()-c,t=e-200*o;t>500&&this.emit("transport",{event:"eventloop_lag",lagMs:t,elapsedMs:e,probeSeq:o})},200),h=()=>{clearInterval(r),clearInterval(a),this.off("connected",h),this.off("disconnected",h)};setTimeout(h,3e4),this.once("connected",h),this.once("disconnected",h)}else"chat"===e?this.emit("chat",t):"agent"===e?this.emit("agent",t):"cron"===e?this.emit("cron",t):"canvas"===e?this.emit("canvas",t):"session.message"===e?this.emit("sessionMessage",t):this.emit("gateway-event",e,t)}else if("hello-ok"===d)this.emit("transport",{event:"handshake.hello_ok_recv",durationMs:Date.now()-n}),r(h);else if("hello-error"===d||"connect-error"===d)c("Authentication rejected by Gateway");else if("res"===d){const e=h.id;if(e&&e===i){if(this.emit("transport",{event:"handshake.connect_res_recv",ok:!!h.ok,durationMs:Date.now()-n}),h.ok){const e=h.payload;r(e??h)}else{const e=h.error;!0===e?.retryable||"UNAVAILABLE"===e?.code?(clearTimeout(o),this.emit("transport",{event:"connect_fail",reason:e?.message||"Gateway unavailable; will retry",retryable:!0,retryAfterMs:e?.retryAfterMs,durationMs:Date.now()-n}),"number"==typeof e?.retryAfterMs&&e.retryAfterMs>0&&(this.nextReconnectDelayHintMs=e.retryAfterMs),s.close(),t(new Error(e?.message||"Gateway unavailable"))):c(e?.message||"Gateway connect rejected")}return}const a=this.pending.get(e);if(a)if(this.pending.delete(e),clearTimeout(a.timer),h.ok)a.resolve(h.payload);else{const e=h.error;a.reject(new Error(e?.message||"Gateway request failed"))}else this.emit("response",h)}}),s.on("pong",()=>{this.lastPongTs=Date.now()}),s.on("close",e=>{const s=this.connected;this.stopHeartbeat(),this.clearReconnectNotify(),this.emit("transport",{event:"close",code:e,durationMs:Date.now()-n}),this.connected=!1,this.connectPromise=null;for(const[,e]of this.pending)clearTimeout(e.timer),e.reject(new Error("Connection closed"));this.pending.clear(),s||(clearTimeout(o),t(new Error(`Connection closed during handshake (code=${e})`))),4001===e&&(this.authFailed=!0,this.emit("auth-failed",{code:e})),this.emit("disconnected"),this.stopped||this.authFailed||this.scheduleReconnect()}),s.on("error",e=>{this.emit("transport",{event:"error",message:e.message,durationMs:Date.now()-n}),this.stopped?clearTimeout(o):(this.connected||(clearTimeout(o),t(e)),this.emit("error",e))})})}emitMessageHash(e){if(e.length<32)return;const t=createHash("sha256").update(e).digest("hex").slice(0,16);this.emit("transport",{event:"message_recv",hash:t,size:e.length})}startHeartbeat(){this.stopHeartbeat(),this.lastPongTs=Date.now(),this.heartbeatTimer=setInterval(()=>{if(!this.ws||!this.connected)return;try{this.ws.ping()}catch{}const e=Date.now()-this.lastPongTs;e>6e4&&(this.emit("liveness-timeout",{elapsed:e}),this.ws.close())},15e3),"object"==typeof this.heartbeatTimer&&"unref"in this.heartbeatTimer&&this.heartbeatTimer.unref()}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}isReconnectNotification(e){return"server/reconnect"===e||e.endsWith("/reconnect")}scheduleReconnectNotify(){this.clearReconnectNotify();const e=6e4+12e4*Math.random();this.emit("reconnect-notify-scheduled",{delayMs:Math.round(e)}),this.reconnectNotifyTimer=setTimeout(()=>{this.reconnectNotifyTimer=null,!this.stopped&&this.connected&&(this.emit("reconnect-notify-fired"),this.reconnectNow())},e),"object"==typeof this.reconnectNotifyTimer&&"unref"in this.reconnectNotifyTimer&&this.reconnectNotifyTimer.unref()}clearReconnectNotify(){this.reconnectNotifyTimer&&(clearTimeout(this.reconnectNotifyTimer),this.reconnectNotifyTimer=null)}scheduleReconnect(){if(this.stopped)return;if(this.reconnectTimer)return;const e=this.config.retry??{baseMs:1e3,maxMs:6e5,maxAttempts:0};if(e.maxAttempts>0&&this.reconnectAttempt>=e.maxAttempts)return void this.emit("reconnect-exhausted",this.reconnectAttempt);let t;null!==this.nextReconnectDelayHintMs&&this.nextReconnectDelayHintMs>0?(t=Math.min(this.nextReconnectDelayHintMs,e.maxMs),this.nextReconnectDelayHintMs=null):t=Math.min(e.baseMs*Math.pow(2,this.reconnectAttempt)+500*Math.random(),e.maxMs),this.reconnectAttempt++,this.emit("reconnecting",{attempt:this.reconnectAttempt,delayMs:t}),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this.stopped||this.connect().catch(e=>{this.emit("error",e instanceof Error?e:new Error(String(e)))})},t),"object"==typeof this.reconnectTimer&&"unref"in this.reconnectTimer&&this.reconnectTimer.unref()}reconnectNow(){this.stopped||(this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.close(),this.reconnectAttempt=0,this.connect().catch(e=>{this.emit("error",e instanceof Error?e:new Error(String(e)))}))}_sendConnect(e){const t=Date.now(),n=Date.now(),s="operator",i=["operator.admin","operator.approvals","operator.pairing"],o={id:this.deviceIdentity.deviceId,publicKey:this.deviceIdentity.publicKeyB64url,signedAt:n};if(e){const t=Date.now(),r=buildDeviceAuthPayload({deviceId:this.deviceIdentity.deviceId,clientId:"cli",clientMode:"cli",role:s,scopes:i,signedAtMs:n,token:this.config.token,nonce:e}),c=Date.now()-t,a=Date.now();o.nonce=e,o.signature=signDeviceAuth(this.deviceIdentity,r);const h=Date.now()-a;this.emit("transport",{event:"handshake.sign_done",buildPayloadMs:c,signMs:h})}const r=randomUUID(),c={type:"req",id:r,method:"connect",params:{minProtocol:3,maxProtocol:4,client:{id:"cli",displayName:"BeeOS Platform Bridge",version:"0.1.0",platform:"beeos",mode:"cli"},caps:this.config.openclawCanvasCompat?["tool-events","canvas"]:["tool-events"],role:s,scopes:i,auth:{token:this.config.token},device:o}},a=Date.now(),h=JSON.stringify(c),l=Date.now()-a,d=Date.now();this.ws?.send(h);const u=Date.now()-d;return this.emit("transport",{event:"handshake._sendConnect_done",totalMs:Date.now()-t,serializeMs:l,sendMs:u}),r}async request(e,t,n=6e4){if(!this.ws||!this.connected)throw new Error("Not connected to Gateway");const s=randomUUID(),i={type:"req",id:s,method:e,params:t};return new Promise((t,o)=>{const r=setTimeout(()=>{this.pending.delete(s),o(new Error(`Gateway request timeout: ${e}`))},n);this.pending.set(s,{resolve:t,reject:o,timer:r}),this.ws.send(JSON.stringify(i))})}async agentSend(e,t,n,s,i){const o={agentId:n,sessionKey:e,message:t,deliver:!1,idempotencyKey:randomUUID()};s?.length&&(o.attachments=s),i&&(o.inputProvenance=i);return await this.request("agent",o,12e4)}async chatSend(e,t,n){const s={sessionKey:e,message:t,deliver:!1,idempotencyKey:randomUUID()};n?.length&&(s.attachments=n);return await this.request("chat.send",s,12e4)}async chatAbort(e,t){await this.request("chat.abort",{sessionKey:e,runId:t})}sendFrame(e){if(!this.ws||!this.connected)return!1;try{return this.ws.send(JSON.stringify(e)),!0}catch{return!1}}agentCancel(e,t){const n={sessionKey:e};t?.runId&&(n.runId=t.runId);const s=`abort_${t?.runId??t?.requestId??e}`;return this.sendFrame({type:"req",id:s,method:"chat.abort",params:n})}async chatHistory(e,t){return this.request("chat.history",{sessionKey:e,limit:t})}async chatHistoryPage(e,t){const n=wsUrlToHttpBase(this.config.url);if(!n)return null;let s;try{s=new URL(`${n}/sessions/${encodeURIComponent(e)}/history`)}catch{return null}null!=t?.limit&&s.searchParams.set("limit",String(t.limit)),t?.cursor&&s.searchParams.set("cursor",t.cursor);try{const e=await fetch(s,{method:"GET",headers:{accept:"application/json",authorization:`Bearer ${this.config.token}`},signal:AbortSignal.timeout(1e4)});if(!e.ok)return null;const t=await e.json(),n=Array.isArray(t.messages)?t.messages:Array.isArray(t.items)?t.items:[];return{messages:n.filter(e=>"object"==typeof e&&null!==e),nextCursor:"string"==typeof t.nextCursor&&t.nextCursor.length>0?t.nextCursor:void 0,hasMore:!0===t.hasMore}}catch{return null}}async sessionsMessagesSubscribe(e){return this.request("sessions.messages.subscribe",{key:e})}async sessionsMessagesUnsubscribe(e){return this.request("sessions.messages.unsubscribe",{key:e})}async skillsStatus(e){return this.request("skills.status",e?{agentId:e}:{})}async skillsUpdate(e,t,n){return this.request("skills.update",{skillKey:e,enabled:t,...n?{agentId:n}:{}})}async sessionsList(){return this.request("sessions.list",{includeDerivedTitles:!0})}async sessionsResolve(e){return this.request("sessions.resolve",{sessionKey:e})}async sessionsPatch(e,t){return this.request("sessions.patch",{key:e,...t})}async sessionsDelete(e){return this.request("sessions.delete",{key:e})}async sessionsReset(e){return this.request("sessions.reset",{key:e,reason:"new"})}close(){if(this.ws)try{this.ws.close()}catch{}this.ws=null,this.connected=!1,this.connectPromise=null,this._currentConnectionId=null}get isConnected(){return this.connected}get connectionSeq(){return this._connectionSeq}get currentConnectionId(){return this._currentConnectionId}get lastReadyAt(){return this._lastReadyAt}}
|
|
1
|
+
import{WebSocket}from"ws";import{EventEmitter}from"node:events";import{randomUUID,createHash}from"node:crypto";import{loadOrCreateDeviceIdentity,buildDeviceAuthPayload,signDeviceAuth,ensureDevicePaired,warmupCryptoSign}from"./device-identity.js";const MIN_PROTOCOL_VERSION=3,MAX_PROTOCOL_VERSION=4,CONNECT_TIMEOUT_MS=3e4,HEARTBEAT_INTERVAL_MS=15e3,LIVENESS_TIMEOUT_MS=6e4,CHAT_HISTORY_PAGE_TIMEOUT_MS=1e4,AUTH_FAILURE_CLOSE_CODE=4001,EL_LAG_INTERVAL_MS=200,EL_LAG_THRESHOLD_MS=500,RECONNECT_NOTIFY_MIN_MS=6e4,RECONNECT_NOTIFY_MAX_MS=18e4,RECONNECT_NOTIFY_METHOD="server/reconnect";export function decodeChatAbortResult(e){if("object"!=typeof e||null===e||Array.isArray(e))throw new Error("Invalid chat.abort response: expected object");const t=e;if("boolean"!=typeof t.aborted)throw new Error("Invalid chat.abort response: expected boolean aborted");if(!Array.isArray(t.runIds)||t.runIds.some(e=>"string"!=typeof e))throw new Error("Invalid chat.abort response: expected string[] runIds");return{aborted:t.aborted,runIds:[...t.runIds]}}function wsUrlToHttpBase(e){try{const t=new URL(e),n="wss:"===t.protocol?"https:":"ws:"===t.protocol?"http:":null;return n?`${n}//${t.host}`:null}catch{return null}}const MANUAL_RECONNECT_SIGNAL="SIGUSR1";function registerReconnectSignal(e,t,n){detachReconnectSignal(e),e.handler=t;try{process.on("SIGUSR1",t),n?.info?.("[gateway] manual reconnect enabled signal=SIGUSR1")}catch{n?.warn?.("[gateway] manual reconnect signal not supported")}}function detachReconnectSignal(e){if(e.handler){try{process.off("SIGUSR1",e.handler)}catch{}e.handler=null}}export class GatewayClient extends EventEmitter{ws=null;config;pending=new Map;connected=!1;stopped=!1;authFailed=!1;connectPromise=null;reconnectTimer=null;reconnectAttempt=0;nextReconnectDelayHintMs=null;signalState={handler:null};deviceIdentity;heartbeatTimer=null;lastPongTs=0;_connectionSeq=0;_currentConnectionId=null;_lastReadyAt=0;reconnectNotifyTimer=null;constructor(e){super(),this.config=e,this.deviceIdentity=loadOrCreateDeviceIdentity();try{ensureDevicePaired(this.deviceIdentity)}catch(e){}Date.now();warmupCryptoSign(this.deviceIdentity)}async start(){this.stopped=!1,this.reconnectAttempt=0,registerReconnectSignal(this.signalState,()=>{this.stopped||(this.emit("manual-reconnect"),this.reconnectNow())}),await this.connect()}stop(){this.stopped=!0,this.stopHeartbeat(),this.clearReconnectNotify(),detachReconnectSignal(this.signalState),this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.close()}async connect(){if(this.connectPromise)return this.connectPromise;this.connectPromise=this._connect();try{await this.connectPromise}finally{this.connectPromise=null}}_connect(){return new Promise((e,t)=>{if(this.authFailed)return void t(new Error("Authentication failed — not retrying"));const n=Date.now();this.emit("transport",{event:"connect_start",url:this.config.url,attempt:this.reconnectAttempt});const s=new WebSocket(this.config.url);this.ws=s;let i=null;const o=setTimeout(()=>{this.emit("transport",{event:"connect_fail",reason:"timeout",durationMs:Date.now()-n}),s.close(),t(new Error("Gateway connection timeout"))},3e4),r=t=>{clearTimeout(o),this.connected=!0,this.reconnectAttempt=0,this.authFailed=!1,this._connectionSeq++,this._currentConnectionId=randomUUID(),this._lastReadyAt=Date.now(),this.startHeartbeat(),this.emit("transport",{event:"connect_ok",connectionSeq:this._connectionSeq,connectionId:this._currentConnectionId,durationMs:Date.now()-n}),this.emit("connected",{...t,connectionSeq:this._connectionSeq,connectionId:this._currentConnectionId}),e()},c=e=>{clearTimeout(o),this.authFailed=!0,this.emit("transport",{event:"connect_fail",reason:e,durationMs:Date.now()-n}),this.emit("auth-failed",{reason:e}),s.close(),t(new Error(e))};s.on("open",()=>{this.emit("transport",{event:"ws_open",durationMs:Date.now()-n})}),s.on("message",e=>{const a="string"==typeof e?e:e.toString("utf-8");if("pong"===a)return void(this.lastPongTs=Date.now());let h;this.emitMessageHash(a);try{h=JSON.parse(a)}catch{return}const l=h.method;l&&!h.id&&(this.isReconnectNotification(l)?this.scheduleReconnectNotify():this.reconnectNotifyTimer&&this.clearReconnectNotify(),this.emit("notification",l,h.params));const d=h.type;if("event"===d&&this.reconnectNotifyTimer&&this.clearReconnectNotify(),"event"===d){const e=h.event,t=h.payload;if("connect.challenge"===e){const e=Date.now();this.emit("transport",{event:"handshake.challenge_recv",durationMs:e-n});const s=t?.nonce;i=this._sendConnect(s),this.emit("transport",{event:"handshake.connect_sent",durationMs:Date.now()-n});let o=0;const r=setInterval(()=>{o++},200),c=Date.now(),a=setInterval(()=>{const e=Date.now()-c,t=e-200*o;t>500&&this.emit("transport",{event:"eventloop_lag",lagMs:t,elapsedMs:e,probeSeq:o})},200),h=()=>{clearInterval(r),clearInterval(a),this.off("connected",h),this.off("disconnected",h)};setTimeout(h,3e4),this.once("connected",h),this.once("disconnected",h)}else"chat"===e?this.emit("chat",t):"agent"===e?this.emit("agent",t):"cron"===e?this.emit("cron",t):"canvas"===e?this.emit("canvas",t):"session.message"===e?this.emit("sessionMessage",t):this.emit("gateway-event",e,t)}else if("hello-ok"===d)this.emit("transport",{event:"handshake.hello_ok_recv",durationMs:Date.now()-n}),r(h);else if("hello-error"===d||"connect-error"===d)c("Authentication rejected by Gateway");else if("res"===d){const e=h.id;if(e&&e===i){if(this.emit("transport",{event:"handshake.connect_res_recv",ok:!!h.ok,durationMs:Date.now()-n}),h.ok){const e=h.payload;r(e??h)}else{const e=h.error;!0===e?.retryable||"UNAVAILABLE"===e?.code?(clearTimeout(o),this.emit("transport",{event:"connect_fail",reason:e?.message||"Gateway unavailable; will retry",retryable:!0,retryAfterMs:e?.retryAfterMs,durationMs:Date.now()-n}),"number"==typeof e?.retryAfterMs&&e.retryAfterMs>0&&(this.nextReconnectDelayHintMs=e.retryAfterMs),s.close(),t(new Error(e?.message||"Gateway unavailable"))):c(e?.message||"Gateway connect rejected")}return}const a=this.pending.get(e);if(a)if(this.pending.delete(e),clearTimeout(a.timer),h.ok)a.resolve(h.payload);else{const e=h.error;a.reject(new Error(e?.message||"Gateway request failed"))}else this.emit("response",h)}}),s.on("pong",()=>{this.lastPongTs=Date.now()}),s.on("close",e=>{const s=this.connected;this.stopHeartbeat(),this.clearReconnectNotify(),this.emit("transport",{event:"close",code:e,durationMs:Date.now()-n}),this.connected=!1,this.connectPromise=null;for(const[,e]of this.pending)clearTimeout(e.timer),e.reject(new Error("Connection closed"));this.pending.clear(),s||(clearTimeout(o),t(new Error(`Connection closed during handshake (code=${e})`))),4001===e&&(this.authFailed=!0,this.emit("auth-failed",{code:e})),this.emit("disconnected"),this.stopped||this.authFailed||this.scheduleReconnect()}),s.on("error",e=>{this.emit("transport",{event:"error",message:e.message,durationMs:Date.now()-n}),this.stopped?clearTimeout(o):(this.connected||(clearTimeout(o),t(e)),this.emit("error",e))})})}emitMessageHash(e){if(e.length<32)return;const t=createHash("sha256").update(e).digest("hex").slice(0,16);this.emit("transport",{event:"message_recv",hash:t,size:e.length})}startHeartbeat(){this.stopHeartbeat(),this.lastPongTs=Date.now(),this.heartbeatTimer=setInterval(()=>{if(!this.ws||!this.connected)return;try{this.ws.ping()}catch{}const e=Date.now()-this.lastPongTs;e>6e4&&(this.emit("liveness-timeout",{elapsed:e}),this.ws.close())},15e3),"object"==typeof this.heartbeatTimer&&"unref"in this.heartbeatTimer&&this.heartbeatTimer.unref()}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}isReconnectNotification(e){return"server/reconnect"===e||e.endsWith("/reconnect")}scheduleReconnectNotify(){this.clearReconnectNotify();const e=6e4+12e4*Math.random();this.emit("reconnect-notify-scheduled",{delayMs:Math.round(e)}),this.reconnectNotifyTimer=setTimeout(()=>{this.reconnectNotifyTimer=null,!this.stopped&&this.connected&&(this.emit("reconnect-notify-fired"),this.reconnectNow())},e),"object"==typeof this.reconnectNotifyTimer&&"unref"in this.reconnectNotifyTimer&&this.reconnectNotifyTimer.unref()}clearReconnectNotify(){this.reconnectNotifyTimer&&(clearTimeout(this.reconnectNotifyTimer),this.reconnectNotifyTimer=null)}scheduleReconnect(){if(this.stopped)return;if(this.reconnectTimer)return;const e=this.config.retry??{baseMs:1e3,maxMs:6e5,maxAttempts:0};if(e.maxAttempts>0&&this.reconnectAttempt>=e.maxAttempts)return void this.emit("reconnect-exhausted",this.reconnectAttempt);let t;null!==this.nextReconnectDelayHintMs&&this.nextReconnectDelayHintMs>0?(t=Math.min(this.nextReconnectDelayHintMs,e.maxMs),this.nextReconnectDelayHintMs=null):t=Math.min(e.baseMs*Math.pow(2,this.reconnectAttempt)+500*Math.random(),e.maxMs),this.reconnectAttempt++,this.emit("reconnecting",{attempt:this.reconnectAttempt,delayMs:t}),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this.stopped||this.connect().catch(e=>{this.emit("error",e instanceof Error?e:new Error(String(e)))})},t),"object"==typeof this.reconnectTimer&&"unref"in this.reconnectTimer&&this.reconnectTimer.unref()}reconnectNow(){this.stopped||(this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.close(),this.reconnectAttempt=0,this.connect().catch(e=>{this.emit("error",e instanceof Error?e:new Error(String(e)))}))}_sendConnect(e){const t=Date.now(),n=Date.now(),s="operator",i=["operator.admin","operator.approvals","operator.pairing"],o={id:this.deviceIdentity.deviceId,publicKey:this.deviceIdentity.publicKeyB64url,signedAt:n};if(e){const t=Date.now(),r=buildDeviceAuthPayload({deviceId:this.deviceIdentity.deviceId,clientId:"cli",clientMode:"cli",role:s,scopes:i,signedAtMs:n,token:this.config.token,nonce:e}),c=Date.now()-t,a=Date.now();o.nonce=e,o.signature=signDeviceAuth(this.deviceIdentity,r);const h=Date.now()-a;this.emit("transport",{event:"handshake.sign_done",buildPayloadMs:c,signMs:h})}const r=randomUUID(),c={type:"req",id:r,method:"connect",params:{minProtocol:3,maxProtocol:4,client:{id:"cli",displayName:"BeeOS Platform Bridge",version:"0.1.0",platform:"beeos",mode:"cli"},caps:this.config.openclawCanvasCompat?["tool-events","canvas"]:["tool-events"],role:s,scopes:i,auth:{token:this.config.token},device:o}},a=Date.now(),h=JSON.stringify(c),l=Date.now()-a,d=Date.now();this.ws?.send(h);const u=Date.now()-d;return this.emit("transport",{event:"handshake._sendConnect_done",totalMs:Date.now()-t,serializeMs:l,sendMs:u}),r}async request(e,t,n=6e4){if(!this.ws||!this.connected)throw new Error("Not connected to Gateway");const s=randomUUID(),i={type:"req",id:s,method:e,params:t};return new Promise((t,o)=>{const r=setTimeout(()=>{this.pending.delete(s),o(new Error(`Gateway request timeout: ${e}`))},n);this.pending.set(s,{resolve:t,reject:o,timer:r}),this.ws.send(JSON.stringify(i))})}async agentSend(e,t,n,s,i){const o={agentId:n,sessionKey:e,message:t,deliver:!1,idempotencyKey:randomUUID()};s?.length&&(o.attachments=s),i&&(o.inputProvenance=i);return await this.request("agent",o,12e4)}async chatSend(e,t,n){const s={sessionKey:e,message:t,deliver:!1,idempotencyKey:randomUUID()};n?.length&&(s.attachments=n);return await this.request("chat.send",s,12e4)}async chatAbort(e,t){const n={sessionKey:e};t&&(n.runId=t);return decodeChatAbortResult(await this.request("chat.abort",n))}sendFrame(e){if(!this.ws||!this.connected)return!1;try{return this.ws.send(JSON.stringify(e)),!0}catch{return!1}}agentCancel(e,t){const n={sessionKey:e};t?.runId&&(n.runId=t.runId);const s=`abort_${t?.runId??t?.requestId??e}`;return this.sendFrame({type:"req",id:s,method:"chat.abort",params:n})}async chatHistory(e,t){return this.request("chat.history",{sessionKey:e,limit:t})}async chatHistoryPage(e,t){const n=wsUrlToHttpBase(this.config.url);if(!n)return null;let s;try{s=new URL(`${n}/sessions/${encodeURIComponent(e)}/history`)}catch{return null}null!=t?.limit&&s.searchParams.set("limit",String(t.limit)),t?.cursor&&s.searchParams.set("cursor",t.cursor);try{const e=await fetch(s,{method:"GET",headers:{accept:"application/json",authorization:`Bearer ${this.config.token}`},signal:AbortSignal.timeout(1e4)});if(!e.ok)return null;const t=await e.json(),n=Array.isArray(t.messages)?t.messages:Array.isArray(t.items)?t.items:[];return{messages:n.filter(e=>"object"==typeof e&&null!==e),nextCursor:"string"==typeof t.nextCursor&&t.nextCursor.length>0?t.nextCursor:void 0,hasMore:!0===t.hasMore}}catch{return null}}async sessionsMessagesSubscribe(e){return this.request("sessions.messages.subscribe",{key:e})}async sessionsMessagesUnsubscribe(e){return this.request("sessions.messages.unsubscribe",{key:e})}async skillsStatus(e){return this.request("skills.status",e?{agentId:e}:{})}async skillsUpdate(e,t,n){return this.request("skills.update",{skillKey:e,enabled:t,...n?{agentId:n}:{}})}async sessionsList(){return this.request("sessions.list",{includeDerivedTitles:!0})}async sessionsResolve(e){return this.request("sessions.resolve",{sessionKey:e})}async sessionsPatch(e,t){return this.request("sessions.patch",{key:e,...t})}async sessionsDelete(e){return this.request("sessions.delete",{key:e})}async sessionsReset(e){return this.request("sessions.reset",{key:e,reason:"new"})}close(){if(this.ws)try{this.ws.close()}catch{}this.ws=null,this.connected=!1,this.connectPromise=null,this._currentConnectionId=null}get isConnected(){return this.connected}get connectionSeq(){return this._connectionSeq}get currentConnectionId(){return this._currentConnectionId}get lastReadyAt(){return this._lastReadyAt}}
|
|
@@ -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{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
|
+
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(e,t)=>({aborted:!0,runIds:t?[t]:[]})}},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(),i=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:o,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"}),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(),i=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:o,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await i.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",async()=>{const t=makeLocalServer(),n=makeLogger(),r=vi.fn(async()=>({ok:!0}));e.register(promptRunKey("chan_xyz","msg_cancel_me"),{requestId:"req_xyz",channelId:"chan_xyz",requestCancel:r,startedAt:Date.now()});const o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a});await o.onChatCancel(buildChatCancel({channelId:"chan_xyz",targetMessageId:"msg_cancel_me",reason:"timeout"})),expect(r).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(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a}),i=buildChatMessage({channelId:"chan_dup",messageId:"msg_dup"});await o.onChatMessage(i),await o.onChatMessage(i),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 +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{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(),
|
|
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=randomUUID(),l=Date.now(),u=e.origin??"im",m={protocol:"im",requestId:d,sessionId:r.sessionKey??"agent:main",sourceMessageId:r.inboundMessageId,chatId:r.channelId,origin:u},p=normalizePromptTimeoutMs(e.promptTimeoutMs),I=promptRunKey(r.channelId,r.inboundMessageId),h=r.sessionKey??"agent:main",g=new AbortController;let y,_,f;const E=new Promise((e,n)=>{f=n}),b=async e=>{if(_??=e,g.abort(e),!y)return{ok:!0};const n=await s.requestCancel(y,o);return n.ok?n.terminalObservation.then(e=>{e.ok||f(new Error(SESSION_RECOVERY_REQUIRED_CODE))}):f(new Error(SESSION_RECOVERY_REQUIRED_CODE)),{ok:n.ok}};if(!i.register(I,{requestId:d,channelId:r.channelId,requestCancel:b,startedAt:l,sessionKey:h}))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 $=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,w=!1;try{w=!0===(await $.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(w)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(I),!1;const M=new SerializedCallbackQueue,S=createImReplySink({stream:$,openReasoningStream:()=>(R=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}),R),abortSignal:g.signal,callbackQueue:M,logger:t,requestId:d,runId:c,channelId:r.channelId,inboundMessageId:r.inboundMessageId,origin:u,onAgentRunStart:e=>{s.bindRun(y,e)}}),v=interceptAgentRuntimeFailures(S,"im_runtime",e=>t.warn?.(`[im] runtime failure reply suppressed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} error_code=${e.errorCode}`)),q=v.sink,C=async(e,n)=>{if(!R)return!1;try{return"cancel"===e?await R.cancel({stopReason:"user_stop"}):"fail"===e?await R.fail(n?{stopReason:n}:void 0):await R.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 k,O,D=!1;const U=async e=>{q.dispatcher.markComplete(e);try{await q.dispatcher.waitForIdle()}catch{}},A=()=>{if(!g.signal.aborted)return;const e=g.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))};let T,F;const K=async e=>{if(!y)throw new Error("session_mutation_not_acquired");const n=await s.converge(y,e);return n.ok?(T=n.permit,!0):(t.error?.(`[im] session mutation recovery required requestId=${d} sessionKey=${h} reason=${n.recovery.reason}`),F={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:h,label:"im_chat_message"},"message_service");if(!a.ok){try{await $.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=${h} 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}y=a.token,s.bindRun(y,c),A(),t.info?.(`[im] runtime dispatching requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} origin=${u}`),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:m,replySinks:{im:q},onDispatchInvoked:()=>{D=!0}});void 0!==p&&(k=setTimeout(()=>{O=new Error(`IM runtime dispatch timed out after ${p}ms`),t.warn?.(`[im] runtime dispatch timeout requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} timeout_ms=${p}`),b(O)},p));const g=Promise.race([o,E]),_=await g.finally(()=>{k&&(clearTimeout(k),k=void 0)}),f=v.detectedFailure();if(f)throw new Error(f.diagnosticMessage);if(A(),await U("success"),A(),!i.claimTerminal(I,"completion")){if(A(),"cancelling"===i.phase(I))throw new Error("cancellation in progress");throw new Error("terminal outcome already claimed")}if(!await K("completed"))return await C("fail"),await $.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",...F?{recovery:F}:{}}),!1;const R=await C("complete");let w=!1;try{await $.finalize({stopReason:"end_turn"})}catch(e){w=!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(_.counts)} elapsed_ms=${Date.now()-l}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,counts:_.counts,...w?{terminalWriteFailed:!0}:{},...R?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await U("failure");const a=g.signal.aborted,s=a?g.signal.reason:void 0,o="cancelling"===i.phase(I),c=void 0!==O,l=s??_,u=!c&&"string"==typeof l&&l.startsWith("chat_cancel");let m="runtime_error";c?m="timeout":(a||o)&&(m="abort");const p=o||u||c||a?"cancellation":"completion";if(!i.claimTerminal(I,p))throw t.warn?.(`[im] terminal write skipped because another outcome already committed requestId=${d} channel=${r.channelId}`),n;const h=u?"cancelled":c?"timeout":"error";if(y&&!await K(h))throw await C("fail"),await $.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,...F?{recovery:F}:{}}),n;const f=classifyAgentRuntimeFailure({raw:n,source:"im_runtime",runtimeKind:"openclaw",requestAborted:u}),E=u?f:v.detectedFailure()??f;u?await C("cancel"):c?await C("fail","timeout"):await C("fail");try{u?await $.cancel({stopReason:"user_stop"}):await $.fail({body:E.publicMessage,stopReason:messageStopReasonForRuntimeFailure(E)})}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=${D} error_code=${E.errorCode}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:E.errorCode,cause:m}),n}finally{i.unregister(I),D?T&&s.release(T):s.releaseBeforeRuntime(y)}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.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";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,a,s="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:s})}appendToolResult(e,t,a=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const a=t.replyOptions?.abortSignal;if(!a)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:a})}}}}),stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,a,s,n={})=>{const o=new SessionWriteGate,r=n.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:a,sessionMutationCoordinator:r,abortTransport:n.abortTransport??{chatAbort:async()=>{}},origin:"im",onLifecycleEvent:e=>s.push(e),...n}};describe("dispatchImInboundPrompt",()=>{let e,t,a;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,a=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(a.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const s=await dispatchImInboundPrompt(baseArgs({},e,t,a));expect(s).toBe(!1),expect(e.streams).toHaveLength(0),expect(a).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("idempotent stream open skips runtime dispatch",async()=>{e.nextOpenIdempotent=!0;let s=!1;const n=makeRuntime(async()=>(s=!0,{counts:{tool:0,block:0,final:0}})),o=await dispatchImInboundPrompt(baseArgs(n,e,t,a));expect(o).toBe(!1),expect(s).toBe(!1),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized).toBeNull(),expect(a).toEqual([{type:"skipped",reason:"idempotent_reply_exists",channelId:"ch_t1",inboundMessageId:"msg_t1"}]),expect(t.size()).toBe(0)}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const s=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise((t,a)=>{e.addEventListener("abort",()=>{const t=e.reason;a(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_request/);const c=e.streams[0];expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop");const p=a.find(e=>"dispatch_error"===e.type);expect(p).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("keeps cancellation authoritative when runtime completes before chat.abort ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e}),p=vi.fn(async()=>{await c}),l=makeRuntime(async({replyOptions:e})=>(e.onAgentRunStart?.("run-cancel-race"),s(),await i,{counts:{tool:0,block:0,final:0}})),m=dispatchImInboundPrompt(baseArgs(l,e,t,a,{abortTransport:{chatAbort:p}}));await r;const d=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");n(),await Promise.resolve(),expect(e.streams[0].finalized).toBeNull(),o(),await expect(d).resolves.toMatchObject({ok:!0}),await expect(m).rejects.toThrow(/chat_cancel:user_request/),expect(p).toHaveBeenCalledWith("agent:main","run-cancel-race"),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined()}),it("keeps timeout authoritative when chat_cancel also becomes pending before abort ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e}),p=makeRuntime(async({replyOptions:e})=>(e.onAgentRunStart?.("run-timeout-race"),s(),await i,{counts:{tool:0,block:0,final:0}})),l=dispatchImInboundPrompt(baseArgs(p,e,t,a,{promptTimeoutMs:5,abortTransport:{chatAbort:async()=>{await c}}}));await r,await new Promise(e=>setTimeout(e,10));const m=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");n(),await Promise.resolve(),o(),await expect(m).resolves.toMatchObject({ok:!0}),await expect(l).rejects.toThrow(/timed out after 5ms/),expect(e.streams[0].finalized).toMatchObject({state:"failed",stopReason:"timeout"}),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"timeout"})}),it("runtime error path: stream fails with a redacted public message",async()=>{const s=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("The agent could not complete the request. Please retry.");const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let s=-1;const n=makeRuntime(async()=>(s=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,a)),expect(s).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",requestCancel:async()=>({ok:!0}),startedAt:Date.now()});const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const s=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),s(),await new Promise((e,a)=>{t.addEventListener("abort",()=>{const e=t.reason;a(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n,await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.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";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,a,s="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:s})}appendToolResult(e,t,a=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const a=t.replyOptions?.abortSignal;if(!a)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:a})}}}}),stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,a,s,n={})=>{const o=new SessionWriteGate,r=n.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:a,sessionMutationCoordinator:r,abortTransport:n.abortTransport??{chatAbort:async(e,t)=>({aborted:!0,runIds:t?[t]:[]})},origin:"im",onLifecycleEvent:e=>s.push(e),...n}};describe("dispatchImInboundPrompt",()=>{let e,t,a;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,a=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(a.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const s=await dispatchImInboundPrompt(baseArgs({},e,t,a));expect(s).toBe(!1),expect(e.streams).toHaveLength(0),expect(a).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("idempotent stream open skips runtime dispatch",async()=>{e.nextOpenIdempotent=!0;let s=!1;const n=makeRuntime(async()=>(s=!0,{counts:{tool:0,block:0,final:0}})),o=await dispatchImInboundPrompt(baseArgs(n,e,t,a));expect(o).toBe(!1),expect(s).toBe(!1),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized).toBeNull(),expect(a).toEqual([{type:"skipped",reason:"idempotent_reply_exists",channelId:"ch_t1",inboundMessageId:"msg_t1"}]),expect(t.size()).toBe(0)}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const s=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{const s=stubLogger();let n;const o=new Promise(e=>{n=e}),r=makeRuntime(async({abortSignal:e})=>(n(),await new Promise((t,a)=>{e.addEventListener("abort",()=>{const t=e.reason;a(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),i=dispatchImInboundPrompt(baseArgs(r,e,t,a,{logger:s}));await o;const c=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(c.ok).toBe(!0),await expect(i).rejects.toThrow(/chat_cancel:user_request/);const p=e.streams[0];expect(p.finalized?.state).toBe("cancelled"),expect(p.finalized?.stopReason).toBe("user_stop");const l=a.find(e=>"dispatch_error"===e.type);expect(l).toMatchObject({cause:"abort",error:"request_aborted"}),expect(s.lines).toContainEqual(expect.stringContaining("cause=abort dispatchInvoked=true error_code=request_aborted")),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort",error:"request_aborted"}),expect(t.size()).toBe(0)}),it("pre-binds a stable runId and aborts locally before an early cancel RPC ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e});let p="";const l=vi.fn(async(e,t)=>(await c,{aborted:!0,runIds:t?[t]:[]})),d=makeRuntime(async({replyOptions:e,abortSignal:t})=>{p=e.runId??"";const a=new Promise((e,a)=>{t.addEventListener("abort",()=>{n(),a(new Error(String(t.reason)))},{once:!0})});return s(),await a,{counts:{tool:0,block:0,final:0}}}),m=dispatchImInboundPrompt(baseArgs(d,e,t,a,{abortTransport:{chatAbort:l}}));await r;const u=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:early");await i,expect(p).toMatch(/^[0-9a-f-]{36}$/),expect(l).toHaveBeenCalledWith("agent:main",p),expect(e.streams[0].finalized).toBeNull(),o(),await expect(u).resolves.toMatchObject({ok:!0}),await expect(m).rejects.toThrow(/chat_cancel:early/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"})}),it("keeps cancellation authoritative when runtime completes before chat.abort ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e});let p="";const l=vi.fn(async(e,t)=>(await c,{aborted:!0,runIds:t?[t]:[]})),d=makeRuntime(async({replyOptions:e})=>(p=e.runId??"",e.onAgentRunStart?.(p),s(),await i,{counts:{tool:0,block:0,final:0}})),m=dispatchImInboundPrompt(baseArgs(d,e,t,a,{abortTransport:{chatAbort:l}}));await r;const u=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");n(),await Promise.resolve(),expect(e.streams[0].finalized).toBeNull(),o(),await expect(u).resolves.toMatchObject({ok:!0}),await expect(m).rejects.toThrow(/chat_cancel:user_request/),expect(p).toMatch(/^[0-9a-f-]{36}$/),expect(l).toHaveBeenCalledWith("agent:main",p),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined()}),it("keeps timeout authoritative when chat_cancel also becomes pending before abort ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e}),p=makeRuntime(async({replyOptions:e})=>(e.onAgentRunStart?.(e.runId??""),s(),await i,{counts:{tool:0,block:0,final:0}})),l=dispatchImInboundPrompt(baseArgs(p,e,t,a,{promptTimeoutMs:5,abortTransport:{chatAbort:async(e,t)=>(await c,{aborted:!0,runIds:t?[t]:[]})}}));await r,await new Promise(e=>setTimeout(e,10));const d=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");n(),await Promise.resolve(),o(),await expect(d).resolves.toMatchObject({ok:!0}),await expect(l).rejects.toThrow(/timed out after 5ms/),expect(e.streams[0].finalized).toMatchObject({state:"failed",stopReason:"timeout"}),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"timeout"})}),it("runtime error path: stream fails with a redacted public message",async()=>{const s=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("The agent could not complete the request. Please retry.");const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error",error:"provider_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let s=-1;const n=makeRuntime(async()=>(s=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,a)),expect(s).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",requestCancel:async()=>({ok:!0}),startedAt:Date.now()});const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const s=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),s(),await new Promise((e,a)=>{t.addEventListener("abort",()=>{const e=t.reason;a(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n,await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{MessageServiceRecoveryScanner}from"./im-recovery-scanner.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{BEEOS_LIST_FILES_TOOL_NAME,BEEOS_LIST_FILES_TOOL_SCHEMA,BEEOS_LIST_FILES_TOOL_DESCRIPTION,executeListFiles}from"./files/list-tool.js";import{BEEOS_FETCH_FILE_TOOL_NAME,BEEOS_FETCH_FILE_TOOL_SCHEMA,BEEOS_FETCH_FILE_TOOL_DESCRIPTION,executeFetchFile}from"./files/fetch-tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{BEEOS_FILE_RESOURCE_ROUTING_RULE}from"./files/beeos-file-uri.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";import{registerArouterMediaProviders}from"./media/index.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null;const sharedSessionWriteGate=new SessionWriteGate,sharedPromptRunStore=new PromptRunStore;let sharedSessionMutationCoordinator,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:t}=await import("node:crypto"),a=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(t(null,Buffer.from(r),a))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:t,error:{code:"unhandled_rejection",message:t}})}),process.on("uncaughtException",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:t,error:{code:"uncaught_exception",message:t}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const t=o.config,a=buildThirdPartyChannelSet(r);a.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...a].join(", ")}`);const n=t.log.enabled?createObsEventWriter({dir:t.log.dir,redaction:{gatewayToken:t.gateway.token},logger:e.logger}):()=>{};n({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),n({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:t,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)n({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),t.keyFile)try{agentKeys=loadAgentKeyPair(t.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&t.agentGatewayUrl&&(setUploadDeps({gatewayUrl:t.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:t,writeObs:n,thirdPartyChannels:a,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:t,writeObs:a}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeUpload(o,t,agentKeys,n);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${s.ok}`),a({component:"upload-tool",domain:"tool",name:s.ok?"upload.success":"upload.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(s)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}}),e.registerTool({name:BEEOS_LIST_FILES_TOOL_NAME,description:BEEOS_LIST_FILES_TOOL_DESCRIPTION,parameters:BEEOS_LIST_FILES_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_list_files] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeListFiles(o,t,agentKeys,n);return a({component:"file-list-tool",domain:"tool",name:s.ok?"list_files.success":"list_files.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),s}}),e.registerTool({name:BEEOS_FETCH_FILE_TOOL_NAME,description:BEEOS_FETCH_FILE_TOOL_DESCRIPTION,parameters:BEEOS_FETCH_FILE_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_fetch_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeFetchFile(o,t,agentKeys,{signal:n,resolvePath:e.resolvePath});return a({component:"file-fetch-tool",domain:"tool",name:s.ok?"fetch_file.success":"fetch_file.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,path:s.path,size:s.size,error:s.error}}),s}})}function registerServices(e,r,o){const{cfg:t,writeObs:a,thirdPartyChannels:n}=r,s=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedSessionMutationCoordinator??=new SessionMutationCoordinator(sharedSessionWriteGate,new OpenClawSessionWriteQuiescence(e.logger,{agentId:t.gateway.agentId||"main"}),e.logger);const l=sharedSessionMutationCoordinator;sharedAcpServer||(sharedAcpServer=new ACPServer(t,e.logger,l,a,s,i,n));const c=sharedAcpServer;o.current=c;const g=sharedPromptRunStore;e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${t.bridge.url||"(empty)"}`),!t.bridge.url&&t.agentGatewayUrl&&s){const r=Date.now(),o=await discoverBridgeFromGW(t.agentGatewayUrl,s);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)t.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(t.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await c.start(t.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await c.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:d,url:p,principalId:u,a2aEnabled:f}=t.messageBus;let m;if(t.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);m=createTokenProvider({agentGatewayUrl:t.agentGatewayUrl,identity:e})}const y=d&&(m||t.messageBus.serviceUrl),w=y?c.getMessageClient():null;if(y&&w){w.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),w.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});let r=u||process.env.BEEOS_PRINCIPAL_ID||"";const o={serviceUrl:t.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:p,tokenProvider:m,logger:e.logger},n=new Map;let s,i=!1;const d=async()=>{if(r||!m)return r;try{const o=await m("");r=o.identityId||"",r&&e.logger.info?.("[beeos-claw] Message bus principal resolved from Agent Gateway token: %s",r)}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve message bus principal from Agent Gateway token: %s",r instanceof Error?r.message:String(r))}return r},f=()=>{if(r)return[{localAgentId:c.getLocalAgentId(),principalId:r}];const e=c.getPlatformAgentMappings().map(({localAgentId:e,platformAgent:r})=>({localAgentId:e,principalId:r.instanceId||""})).filter(e=>e.principalId);if(e.length>0)return e;const o=c.getDefaultPlatformAgent()?.instanceId||"";return o?[{localAgentId:c.getLocalAgentId(),principalId:o}]:[]},y=e=>({getLocalAgentId:()=>e.localAgentId,getDefaultPlatformAgent:()=>({instanceId:e.principalId}),ensureImInboundSession:(e,r,o)=>{c.ensureImInboundSession(e,r,o)},chatAbort:(e,r)=>c.chatAbort(e,r)}),b=async(r,s)=>{if(n.has(r.principalId))return;const i=s?w:new MessageClient(o);s&&c.setMessageClient(o,r.principalId);const d=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:i,localServer:y(r),promptRunStore:g,sessionMutationCoordinator:l,logger:e.logger,writeObs:a,principalIdOverride:r.principalId,promptTimeoutMs:t.promptTimeoutMs}),p=new MessageServiceRecoveryScanner({client:i,imInbound:d,logger:e.logger});i.on("connect",()=>{p.trigger("reconnect"),e.logger.info?.("[beeos-claw] MessageClient connected (localAgentId=%s, principalId=%s, via=%s)",r.localAgentId,r.principalId,m?"gateway":"direct")}),i.on("message",r=>{(async()=>{"chat_message"===r.type?await d.onChatMessage(r):"chat_cancel"===r.type&&await d.onChatCancel(r)})().catch(r=>{e.logger.warn?.(`[beeos-claw] message handler failed: ${r instanceof Error?r.message:String(r)}`)})}),n.set(r.principalId,{client:i,scanner:p,primary:s});try{await i.connect({identityId:r.principalId}),p.start(r.principalId)}catch(e){throw n.delete(r.principalId),p.stop(),s||await i.disconnect().catch(()=>{}),e}},_=async r=>{if(i)if(s)await s;else{s=(async()=>{const o=f();if(0===o.length)return void e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled");const t=new Set(o.map(e=>e.principalId));let a=!1;for(const[o,s]of n)t.has(o)||(s.primary&&(a=!0),s.scanner.stop(),await s.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected stale identity principalId=%s reason=%s",o,r));if(a&&n.size>0)for(const[o,t]of n)t.scanner.stop(),await t.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected for primary rebalance principalId=%s reason=%s",o,r);let s=[...n.values()].some(e=>e.primary);for(const e of o){const r=!s;await b(e,r),r&&(s=!0)}})();try{await s}finally{s=void 0}}};e.registerService({id:"beeos-message-bus",async start(){if(i=!0,c.onAgentMappingUpdated(()=>{_("agent-mapping-updated").catch(r=>{e.logger.error?.("[beeos-claw] Message bus mapping sync failed:",r)})}),await d(),!r)try{await c.waitForAgentMapping()}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}await _("startup")},async stop(){i=!1;const r=[...n.values()];n.clear();for(const e of r)e.scanner.stop(),await e.client.disconnect();e.logger.info?.("[beeos-claw] MessageClient disconnected")}})}else d?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",d);if(!1!==f&&t.agentGatewayUrl){const r=e=>c.getGatewaySessionKeyForToolCall(e),o=e=>c.getMentionsForToolCall(e),a=(e,r)=>{c.broadcastNotification(e,r)},n=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:t.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},n,r,o,a,()=>c?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(t.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(t.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=t.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const a=a=>{const n=new CanvasRelayClient({canvasRelayUrl:t.canvasRelayUrl,agentKeys:agentKeys,agentId:a.id,displayName:a.displayName,avatarUrl:a.avatarUrl,tokenProvider:o,onReconnect:()=>c.onRelayReconnected(),logger:e.logger});n.connect(),r.setRelayClient(n),r.setAgentId(a.id),c.wireRelayActionHandler(),c.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${a.id}, name=${a.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await c.waitForAgentMapping();const r=c.getDefaultPlatformAgent();if(r?.id)return void a(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),c.onceAgentMappingReady(()=>{const r=c.getDefaultPlatformAgent();if(r?.id)try{a(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>a(e),isCanvasActive:e=>!!e&&c.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>c.getSessionIdForToolCall(e),getPlatformAgentId:e=>c.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>c.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>c.waitForAgentMapping()},s=new FormSchemaRegistry,i=buildCronSchemas(n,{capMs:t.cronMaxDurationCapMs});s.register(i.create),s.register(i.update);const l=new ResourceDescriptorRegistry;l.register(CRON_RESOURCE);const g=e=>async(r,o,t)=>(c.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),c.setCanvasState(r,{schemaRegistry:s,descriptorRegistry:l});const d=c.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(s),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:s,builder:d,logger:e.logger}))});const p=c.getResourceRenderer();if(p){generateActionTools(l,e,async(e,r)=>c.gatewayRequest(e,r),p,e.logger)}const u=`\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\n- ${BEEOS_FILE_RESOURCE_ROUTING_RULE}\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.`,f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return c.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&c.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?c.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const b=new TerminalManager,_=createTerminalWebSocketService({config:{port:t.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:t.terminalWsAllowedOrigins},terminalMgr:b,logger:e.logger,writeObs:a});if(t.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){_.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${t.terminalWsPort}`)},stop(){_.stop(),b.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerArouterMediaProviders(e),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
|
|
1
|
+
import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";import{scheduleProcessRuntimeRestart}from"./runtime/runtime-restart-scheduler.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{MessageServiceRecoveryScanner}from"./im-recovery-scanner.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{BEEOS_LIST_FILES_TOOL_NAME,BEEOS_LIST_FILES_TOOL_SCHEMA,BEEOS_LIST_FILES_TOOL_DESCRIPTION,executeListFiles}from"./files/list-tool.js";import{BEEOS_FETCH_FILE_TOOL_NAME,BEEOS_FETCH_FILE_TOOL_SCHEMA,BEEOS_FETCH_FILE_TOOL_DESCRIPTION,executeFetchFile}from"./files/fetch-tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{BEEOS_FILE_RESOURCE_ROUTING_RULE}from"./files/beeos-file-uri.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";import{registerArouterMediaProviders}from"./media/index.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null;const sharedSessionWriteGate=new SessionWriteGate,sharedPromptRunStore=new PromptRunStore;let sharedSessionMutationCoordinator,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:t}=await import("node:crypto"),a=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(t(null,Buffer.from(r),a))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:t,error:{code:"unhandled_rejection",message:t}})}),process.on("uncaughtException",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:t,error:{code:"uncaught_exception",message:t}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const t=o.config,a=buildThirdPartyChannelSet(r);a.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...a].join(", ")}`);const n=t.log.enabled?createObsEventWriter({dir:t.log.dir,redaction:{gatewayToken:t.gateway.token},logger:e.logger}):()=>{};n({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),n({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:t,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)n({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),t.keyFile)try{agentKeys=loadAgentKeyPair(t.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&t.agentGatewayUrl&&(setUploadDeps({gatewayUrl:t.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:t,writeObs:n,thirdPartyChannels:a,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:t,writeObs:a}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeUpload(o,t,agentKeys,n);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${s.ok}`),a({component:"upload-tool",domain:"tool",name:s.ok?"upload.success":"upload.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(s)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}}),e.registerTool({name:BEEOS_LIST_FILES_TOOL_NAME,description:BEEOS_LIST_FILES_TOOL_DESCRIPTION,parameters:BEEOS_LIST_FILES_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_list_files] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeListFiles(o,t,agentKeys,n);return a({component:"file-list-tool",domain:"tool",name:s.ok?"list_files.success":"list_files.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),s}}),e.registerTool({name:BEEOS_FETCH_FILE_TOOL_NAME,description:BEEOS_FETCH_FILE_TOOL_DESCRIPTION,parameters:BEEOS_FETCH_FILE_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_fetch_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeFetchFile(o,t,agentKeys,{signal:n,resolvePath:e.resolvePath});return a({component:"file-fetch-tool",domain:"tool",name:s.ok?"fetch_file.success":"fetch_file.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,path:s.path,size:s.size,error:s.error}}),s}})}function registerServices(e,r,o){const{cfg:t,writeObs:a,thirdPartyChannels:n}=r,s=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedSessionMutationCoordinator??=new SessionMutationCoordinator(sharedSessionWriteGate,new OpenClawSessionWriteQuiescence(e.logger,{agentId:t.gateway.agentId||"main"}),e.logger,{onRecoveryRequired:r=>{scheduleProcessRuntimeRestart(r,e.logger)}});const l=sharedSessionMutationCoordinator;sharedAcpServer||(sharedAcpServer=new ACPServer(t,e.logger,l,a,s,i,n));const c=sharedAcpServer;o.current=c;const g=sharedPromptRunStore;e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${t.bridge.url||"(empty)"}`),!t.bridge.url&&t.agentGatewayUrl&&s){const r=Date.now(),o=await discoverBridgeFromGW(t.agentGatewayUrl,s);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)t.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(t.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await c.start(t.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await c.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:d,url:p,principalId:u,a2aEnabled:f}=t.messageBus;let m;if(t.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);m=createTokenProvider({agentGatewayUrl:t.agentGatewayUrl,identity:e})}const y=d&&(m||t.messageBus.serviceUrl),w=y?c.getMessageClient():null;if(y&&w){w.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),w.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});let r=u||process.env.BEEOS_PRINCIPAL_ID||"";const o={serviceUrl:t.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:p,tokenProvider:m,logger:e.logger},n=new Map;let s,i=!1;const d=async()=>{if(r||!m)return r;try{const o=await m("");r=o.identityId||"",r&&e.logger.info?.("[beeos-claw] Message bus principal resolved from Agent Gateway token: %s",r)}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve message bus principal from Agent Gateway token: %s",r instanceof Error?r.message:String(r))}return r},f=()=>{if(r)return[{localAgentId:c.getLocalAgentId(),principalId:r}];const e=c.getPlatformAgentMappings().map(({localAgentId:e,platformAgent:r})=>({localAgentId:e,principalId:r.instanceId||""})).filter(e=>e.principalId);if(e.length>0)return e;const o=c.getDefaultPlatformAgent()?.instanceId||"";return o?[{localAgentId:c.getLocalAgentId(),principalId:o}]:[]},y=e=>({getLocalAgentId:()=>e.localAgentId,getDefaultPlatformAgent:()=>({instanceId:e.principalId}),ensureImInboundSession:(e,r,o)=>{c.ensureImInboundSession(e,r,o)},chatAbort:(e,r)=>c.chatAbort(e,r)}),b=async(r,s)=>{if(n.has(r.principalId))return;const i=s?w:new MessageClient(o);s&&c.setMessageClient(o,r.principalId);const d=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:i,localServer:y(r),promptRunStore:g,sessionMutationCoordinator:l,logger:e.logger,writeObs:a,principalIdOverride:r.principalId,promptTimeoutMs:t.promptTimeoutMs}),p=new MessageServiceRecoveryScanner({client:i,imInbound:d,logger:e.logger});i.on("connect",()=>{p.trigger("reconnect"),e.logger.info?.("[beeos-claw] MessageClient connected (localAgentId=%s, principalId=%s, via=%s)",r.localAgentId,r.principalId,m?"gateway":"direct")}),i.on("message",r=>{(async()=>{"chat_message"===r.type?await d.onChatMessage(r):"chat_cancel"===r.type&&await d.onChatCancel(r)})().catch(r=>{e.logger.warn?.(`[beeos-claw] message handler failed: ${r instanceof Error?r.message:String(r)}`)})}),n.set(r.principalId,{client:i,scanner:p,primary:s});try{await i.connect({identityId:r.principalId}),p.start(r.principalId)}catch(e){throw n.delete(r.principalId),p.stop(),s||await i.disconnect().catch(()=>{}),e}},_=async r=>{if(i)if(s)await s;else{s=(async()=>{const o=f();if(0===o.length)return void e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled");const t=new Set(o.map(e=>e.principalId));let a=!1;for(const[o,s]of n)t.has(o)||(s.primary&&(a=!0),s.scanner.stop(),await s.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected stale identity principalId=%s reason=%s",o,r));if(a&&n.size>0)for(const[o,t]of n)t.scanner.stop(),await t.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected for primary rebalance principalId=%s reason=%s",o,r);let s=[...n.values()].some(e=>e.primary);for(const e of o){const r=!s;await b(e,r),r&&(s=!0)}})();try{await s}finally{s=void 0}}};e.registerService({id:"beeos-message-bus",async start(){if(i=!0,c.onAgentMappingUpdated(()=>{_("agent-mapping-updated").catch(r=>{e.logger.error?.("[beeos-claw] Message bus mapping sync failed:",r)})}),await d(),!r)try{await c.waitForAgentMapping()}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}await _("startup")},async stop(){i=!1;const r=[...n.values()];n.clear();for(const e of r)e.scanner.stop(),await e.client.disconnect();e.logger.info?.("[beeos-claw] MessageClient disconnected")}})}else d?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",d);if(!1!==f&&t.agentGatewayUrl){const r=e=>c.getGatewaySessionKeyForToolCall(e),o=e=>c.getMentionsForToolCall(e),a=(e,r)=>{c.broadcastNotification(e,r)},n=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:t.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},n,r,o,a,()=>c?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(t.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(t.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=t.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const a=a=>{const n=new CanvasRelayClient({canvasRelayUrl:t.canvasRelayUrl,agentKeys:agentKeys,agentId:a.id,displayName:a.displayName,avatarUrl:a.avatarUrl,tokenProvider:o,onReconnect:()=>c.onRelayReconnected(),logger:e.logger});n.connect(),r.setRelayClient(n),r.setAgentId(a.id),c.wireRelayActionHandler(),c.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${a.id}, name=${a.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await c.waitForAgentMapping();const r=c.getDefaultPlatformAgent();if(r?.id)return void a(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),c.onceAgentMappingReady(()=>{const r=c.getDefaultPlatformAgent();if(r?.id)try{a(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>a(e),isCanvasActive:e=>!!e&&c.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>c.getSessionIdForToolCall(e),getPlatformAgentId:e=>c.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>c.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>c.waitForAgentMapping()},s=new FormSchemaRegistry,i=buildCronSchemas(n,{capMs:t.cronMaxDurationCapMs});s.register(i.create),s.register(i.update);const l=new ResourceDescriptorRegistry;l.register(CRON_RESOURCE);const g=e=>async(r,o,t)=>(c.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),c.setCanvasState(r,{schemaRegistry:s,descriptorRegistry:l});const d=c.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(s),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:s,builder:d,logger:e.logger}))});const p=c.getResourceRenderer();if(p){generateActionTools(l,e,async(e,r)=>c.gatewayRequest(e,r),p,e.logger)}const u=`\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\n- ${BEEOS_FILE_RESOURCE_ROUTING_RULE}\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.`,f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return c.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&c.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?c.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const b=new TerminalManager,_=createTerminalWebSocketService({config:{port:t.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:t.terminalWsAllowedOrigins},terminalMgr:b,logger:e.logger,writeObs:a});if(t.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){_.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${t.terminalWsPort}`)},stop(){_.stop(),b.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerArouterMediaProviders(e),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{formatFriendlyError}from"../errors/classify.js";export const INTERRUPTED_PUBLIC_MESSAGE="The previous run was interrupted before it completed.";export const TIMEOUT_PUBLIC_MESSAGE="The agent task timed out. Please retry.";export const GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE="The agent could not complete the request. Please retry.";const OPENCLAW_SESSION_LOCK_PATTERNS=[/^\s*(?:⚠️\s*)?agent failed before reply:\s*session file locked\b/i,/^\s*sessionwritelocktimeouterror\b/i,/^\s*OPENCLAW_SESSION_WRITE_LOCK\b/i],ABORT_PATTERNS=[/^\s*request was aborted\.?\s*$/i,/^\s*this operation was aborted\.?\s*$/i,/^\s*aborterror\b/i,/^\s*agent failed before reply:\s*.*\baborted\b/i],AGENT_LOST_PATTERNS=[/^\s*(?:agent failed before reply:\s*)?gateway disconnected\b/i,/^\s*agent lost\b/i,/^\s*no live owner\b/i,/^\s*stale live stream\b/i,/^\s*bridge_not_ready\b/i,/^\s*gateway not connected\b/i],TIMEOUT_PATTERNS=[/^\s*prompt timed out\b/i,/^\s*im runtime dispatch timed out\b/i,/^\s*gateway\b.*\btimed out\b/i,/^\s*deadline exceeded\b/i];function matchesAny(e,r){return r.some(r=>r.test(e))}function extractLockDiagnostics(e){const r=e.match(/\bpid=(\d+)\b/i)?.[1],t=e.match(/([^\s]+\.jsonl\.lock)\b/)?.[1];return{...r?{lockPid:r}:{},...t?{lockPath:t}:{}}}function asString(e){if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}}export function classifyAgentRuntimeFailure(e){return detectAgentRuntimeFailure(e)??classifyProviderRuntimeFailure(e)}export function detectAgentRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim();return matchesAny(t,OPENCLAW_SESSION_LOCK_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",diagnosticMessage:t,source:e.source,runtimeKind:r,...extractLockDiagnostics(t)}:matchesAny(t,ABORT_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,AGENT_LOST_PATTERNS)?{kind:"interrupted",stopReason:"agent_lost",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"agent_lost",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,TIMEOUT_PATTERNS)?{kind:"interrupted",stopReason:"timeout",publicMessage:TIMEOUT_PUBLIC_MESSAGE,errorCode:"gateway_timeout",diagnosticMessage:t,source:e.source,runtimeKind:r}:void 0}function classifyProviderRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim(),i=t.toLowerCase(),s=formatFriendlyError(t);return{kind:"error",stopReason:"error",publicMessage:s&&s!==t?s:GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE,errorCode:i?"provider_error":"unknown_runtime_error",diagnosticMessage:t,source:e.source,runtimeKind:r}}export function isRuntimeFailureInterrupted(e){return"interrupted"===e.kind}
|
|
1
|
+
import{formatFriendlyError}from"../errors/classify.js";export const INTERRUPTED_PUBLIC_MESSAGE="The previous run was interrupted before it completed.";export const TIMEOUT_PUBLIC_MESSAGE="The agent task timed out. Please retry.";export const GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE="The agent could not complete the request. Please retry.";const OPENCLAW_SESSION_LOCK_PATTERNS=[/^\s*(?:⚠️\s*)?agent failed before reply:\s*session file locked\b/i,/^\s*sessionwritelocktimeouterror\b/i,/^\s*OPENCLAW_SESSION_WRITE_LOCK\b/i],ABORT_PATTERNS=[/^\s*request was aborted\.?\s*$/i,/^\s*this operation was aborted\.?\s*$/i,/^\s*aborterror\b/i,/^\s*agent failed before reply:\s*.*\baborted\b/i],AGENT_LOST_PATTERNS=[/^\s*(?:agent failed before reply:\s*)?gateway disconnected\b/i,/^\s*agent lost\b/i,/^\s*no live owner\b/i,/^\s*stale live stream\b/i,/^\s*bridge_not_ready\b/i,/^\s*gateway not connected\b/i],TIMEOUT_PATTERNS=[/^\s*prompt timed out\b/i,/^\s*im runtime dispatch timed out\b/i,/^\s*gateway\b.*\btimed out\b/i,/^\s*deadline exceeded\b/i];function matchesAny(e,r){return r.some(r=>r.test(e))}function extractLockDiagnostics(e){const r=e.match(/\bpid=(\d+)\b/i)?.[1],t=e.match(/([^\s]+\.jsonl\.lock)\b/)?.[1];return{...r?{lockPid:r}:{},...t?{lockPath:t}:{}}}function asString(e){if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}}export function classifyAgentRuntimeFailure(e){return detectAgentRuntimeFailure(e)??classifyProviderRuntimeFailure(e)}export function detectAgentRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim();return e.requestAborted?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,OPENCLAW_SESSION_LOCK_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",diagnosticMessage:t,source:e.source,runtimeKind:r,...extractLockDiagnostics(t)}:matchesAny(t,ABORT_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,AGENT_LOST_PATTERNS)?{kind:"interrupted",stopReason:"agent_lost",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"agent_lost",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,TIMEOUT_PATTERNS)?{kind:"interrupted",stopReason:"timeout",publicMessage:TIMEOUT_PUBLIC_MESSAGE,errorCode:"gateway_timeout",diagnosticMessage:t,source:e.source,runtimeKind:r}:void 0}function classifyProviderRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim(),i=t.toLowerCase(),s=formatFriendlyError(t);return{kind:"error",stopReason:"error",publicMessage:s&&s!==t?s:GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE,errorCode:i?"provider_error":"unknown_runtime_error",diagnosticMessage:t,source:e.source,runtimeKind:r}}export function isRuntimeFailureInterrupted(e){return"interrupted"===e.kind}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it}from"vitest";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";describe("classifyAgentRuntimeFailure",()=>{it("redacts OpenClaw session lock diagnostics into an interrupted state",()=>{const e=classifyAgentRuntimeFailure({raw:"Agent failed before reply: session file locked (timeout 120000ms): pid=357 /home/node/.openclaw/agents/main/sessions/s1.jsonl.lock.",source:"history_replay",runtimeKind:"openclaw"});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",lockPid:"357",lockPath:"/home/node/.openclaw/agents/main/sessions/s1.jsonl.lock"}),expect(e.publicMessage).not.toContain(".jsonl.lock"),expect(e.publicMessage).not.toContain("pid=357")}),it("maps abort diagnostics to interrupted",()=>{expect(classifyAgentRuntimeFailure({raw:"Request was aborted.",source:"live_gateway"})).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted"})}),it("keeps provider/model failures as error with friendly public text",()=>{const e=classifyAgentRuntimeFailure({raw:"Provider returned 429 rate limit",source:"live_gateway"});expect(e.kind).toBe("error"),expect(e.stopReason).toBe("error"),expect(e.errorCode).toBe("provider_error"),expect(e.publicMessage.length).toBeGreaterThan(0)}),it("never exposes an unknown diagnostic or credential as public text",()=>{const e="unexpected gateway detail token=super-secret /home/node/private",t=classifyAgentRuntimeFailure({raw:e,source:"im_runtime"});expect(t.publicMessage).toBe("The agent could not complete the request. Please retry."),expect(t.publicMessage).not.toContain("super-secret"),expect(t.diagnosticMessage).toBe(e)})});
|
|
1
|
+
import{describe,expect,it}from"vitest";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";describe("classifyAgentRuntimeFailure",()=>{it("redacts OpenClaw session lock diagnostics into an interrupted state",()=>{const e=classifyAgentRuntimeFailure({raw:"Agent failed before reply: session file locked (timeout 120000ms): pid=357 /home/node/.openclaw/agents/main/sessions/s1.jsonl.lock.",source:"history_replay",runtimeKind:"openclaw"});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",lockPid:"357",lockPath:"/home/node/.openclaw/agents/main/sessions/s1.jsonl.lock"}),expect(e.publicMessage).not.toContain(".jsonl.lock"),expect(e.publicMessage).not.toContain("pid=357")}),it("maps abort diagnostics to interrupted",()=>{expect(classifyAgentRuntimeFailure({raw:"Request was aborted.",source:"live_gateway"})).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted"})}),it("uses authoritative cancellation state without discarding the raw diagnostic",()=>{const e=classifyAgentRuntimeFailure({raw:new Error("opaque runtime rejection"),source:"im_runtime",requestAborted:!0});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted",diagnosticMessage:"opaque runtime rejection"})}),it("keeps provider/model failures as error with friendly public text",()=>{const e=classifyAgentRuntimeFailure({raw:"Provider returned 429 rate limit",source:"live_gateway"});expect(e.kind).toBe("error"),expect(e.stopReason).toBe("error"),expect(e.errorCode).toBe("provider_error"),expect(e.publicMessage.length).toBeGreaterThan(0)}),it("never exposes an unknown diagnostic or credential as public text",()=>{const e="unexpected gateway detail token=super-secret /home/node/private",t=classifyAgentRuntimeFailure({raw:e,source:"im_runtime"});expect(t.publicMessage).toBe("The agent could not complete the request. Please retry."),expect(t.publicMessage).not.toContain("super-secret"),expect(t.diagnosticMessage).toBe(e)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=new Map,i=new Map,a=[],l=[];let d=0,u=0;const g=(t,n,r)=>{const o=u;return e.stream.appendToolUse(t,n,r,"streaming"),u+=1,o},m=()=>(d+=1,`tool_${e.requestId}_${d}`),c=e=>{const t=a.indexOf(e);t>=0&&a.splice(t,1)},p=e=>{const t=e?a.filter(t=>s.get(t)?.name===e):a;return 1===t.length?t[0]:void 0},f=e=>{const t=l.map((e,t)=>({hint:e,index:t})).filter(({hint:t})=>!e||t.name===e);if(1!==t.length)return;const[{hint:n,index:r}]=t;return l.splice(r,1),n},h=(e,t)=>{i.set(e,t)},I=e=>i.get(e)??e,y=e=>null!=e&&("object"!=typeof e||(Array.isArray(e)?e.length>0:Object.keys(e).length>0)),v=(t,n,r,o)=>{const i=s.get(t);if(i){const s="tool"===i.name&&"tool"!==n?n:i.name,l=!y(i.arguments)&&y(r)?r:i.arguments;return s===i.name&&l===i.arguments||(e.stream.replacePart(i.partIndex,{type:"tool_use",id:i.id,name:s,arguments:l,state:i.state}),i.name=s,i.arguments=l),o&&!a.includes(t)&&"streaming"===i.state&&a.push(t),i}const l={id:t,name:n,arguments:r,partIndex:g(t,n,r),state:"streaming",itemEventObserved:!1,toolStartObserved:!1};return s.set(t,l),o&&a.push(t),l},$=t=>{"done"!==t.state&&(e.stream.replacePart(t.partIndex,{type:"tool_use",id:t.id,name:t.name,arguments:t.arguments,state:"done"}),t.state="done")},x=e=>{if(null===e||"object"!=typeof e)return JSON.stringify(e)??String(e);if(Array.isArray(e))return`[${e.map(x).join(",")}]`;return`{${Object.entries(e).sort(([e],[t])=>e.localeCompare(t)).map(([e,t])=>`${JSON.stringify(e)}:${x(t)}`).join(",")}}`},b=(e,t,n)=>{const r=i.get(e);if(r)return v(r,t??"tool",n??{},!1);if(s.has(e))return v(e,t??"tool",n??{},!1);const o=p(t);return o?(h(e,o),v(o,t??"tool",n??{},!1)):v(e,t??"tool",n??{},!1)},_=(e,t)=>{const n=i.get(e);if(n)return v(n,t,{},!1);if(s.has(e))return v(e,t,{},!1);const r=(e=>{const t=a.filter(t=>{const n=s.get(t);return!(void 0===n||e&&n.name!==e||n.itemEventObserved)});return 1===t.length?t[0]:void 0})(t);if(r)return h(e,r),v(r,t,{},!1);const o=f(t);return v(e,t,o?.arguments??{},!1)},k={tool:0,block:0,final:0};let S=!1;const R=(t,n)=>{e.callbackQueue.enqueueSync(()=>{if((t=>e.abortSignal.aborted?(e.logger.debug?.(`[im-sink] ${t} ignored after abort requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):e.stream.isTerminated?(e.logger.debug?.(`[im-sink] ${t} ignored after stream_terminated requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):!S||(e.logger.debug?.(`[im-sink] ${t} ignored after mark_complete requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1))(t))try{n()}catch(n){const r=n instanceof Error?n.message:String(n);e.logger.warn?.(`[im-sink] ${t} mapping failed requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId} error=${r}`)}})};return{dispatcher:{sendToolResult:t=>(R("tool_progress",()=>{const n=resolveRuntimeToolCallId(t),r=resolveRuntimeToolName(t),o=n?void 0:r?p(r):p(),i=n||o?void 0:f(r);if(!n&&!o&&!i)return void e.logger.debug?.(`[im-sink] ambiguous id-less tool progress ignored requestId=${e.requestId} channel=${e.channelId} tool=${r??"unknown"} pending=${a.length}`);const l=n?b(n,r,resolveRuntimeToolArguments(t)):v(o??m(),r??s.get(o??"")?.name??i.name,y(resolveRuntimeToolArguments(t))?resolveRuntimeToolArguments(t):i?.arguments??{},!1);k.tool+=1,((t,n,r)=>{const o=`${r?"1":"0"}:${x(n)}`;if(t.progressSignature===o)return;const s={type:"custom",kind:"tool_progress",data:{tool_use_id:t.id,content:n,...r?{is_error:!0}:{}}};if(void 0===t.progressPartIndex){const n=u;return e.stream.addPart(s),t.progressPartIndex=n,t.progressSignature=o,void(u+=1)}e.stream.replacePart(t.progressPartIndex,s),t.progressSignature=o})(l,(e=>{const t=[e.mediaUrl,...e.mediaUrls??[]].filter(e=>isNonEmptyString(e));return isNonEmptyString(e.text)&&t.length>0?{text:e.text,media_urls:t}:isNonEmptyString(e.text)?e.text:1===t.length?t[0]:t.length>1?{media_urls:t}:!0===e.isError?"Tool execution failed.":""})(t),!0===t.isError)}),!0),sendBlockReply:n=>(R("block_reply",()=>{k.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(R("final_reply",()=>{k.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...k}),markComplete:t=>{if(1===l.length){const e=l.shift();v(m(),e.name,e.arguments,!1)}for(const n of s.values())try{"success"===t&&$(n),c(n.id)}catch(t){const r=t instanceof Error?t.message:String(t);e.logger.warn?.(`[im-sink] tool finalization failed requestId=${e.requestId} toolUseId=${n.id} error=${r}`)}l.length=0,S=!0}},replyOptions:{abortSignal:e.abortSignal,onAgentRunStart:t=>{e.onAgentRunStart?.(t),e.logger.info?.(`[im-sink] run_start requestId=${e.requestId} runId=${t} channel=${e.channelId} in_reply_to=${e.inboundMessageId} origin=${e.origin??"im"}`)},onPartialReply:n=>{R("partial",()=>{const r=extractBodyUpdate(t,n.text);switch(r.kind){case"append":e.stream.appendBody(r.text);break;case"replace":e.stream.setBody(r.text)}})},onReasoningStream:t=>{R("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const s=t.text;""===o&&""!==r&&(n.appendBody("\n"),r+="\n"),s!==o&&(s.startsWith(o)?(n.appendBody(s.slice(o.length)),o=s):o.startsWith(s)||(n.setBody(r+s),o=s))})},onReasoningEnd:()=>{R("reasoning_end",()=>{var t;""!==o&&(t=o,e.stream.appendThinking(t,"done"),u+=1,r+=o,o="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{R("tool_start",()=>{const n="string"==typeof t.phase?t.phase:"start",r=resolveRuntimeToolName(t)??"tool",o=resolveRuntimeToolCallId(t);if("update"===n){if(o){const e=I(o);s.has(e)&&v(e,r,resolveRuntimeToolArguments(t),!0)}return}const i=resolveRuntimeToolArguments(t);if(o){const e=b(o,r,i);return e.toolStartObserved=!0,void(a.includes(e.id)||"streaming"!==e.state||a.push(e.id))}const d=(u=r,a.map(e=>s.get(e)).filter(e=>void 0!==e&&(!u||e.name===u)&&e.itemEventObserved));var u;if(1===d.length){return void(v(d[0].id,r,i,!0).toolStartObserved=!0)}d.length>1?e.logger.debug?.(`[im-sink] ambiguous id-less tool start ignored requestId=${e.requestId} channel=${e.channelId} tool=${r} candidates=${d.length}`):l.push({name:r,arguments:i})})},onItemEvent:e=>{R("item_event",()=>{const t="string"==typeof e.kind?e.kind.toLowerCase():"";if(t&&"tool"!==t)return;const n="string"==typeof e.phase?e.phase.toLowerCase():"update",r=resolveRuntimeToolCallId(e)??("string"==typeof e.itemId&&e.itemId.trim()?e.itemId.trim():void 0),o=resolveRuntimeToolName(e)??("string"==typeof e.title&&e.title.trim()?e.title.trim():void 0);if(!r||!o)return;if("update"===n){const e=I(r),t=s.get(e);return void(t&&(v(t.id,o,{},!0).itemEventObserved=!0))}const i=_(r,o);i.itemEventObserved=!0,a.includes(i.id)||"streaming"!==i.state||a.push(i.id)})}}}}
|
|
1
|
+
import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=new Map,i=new Map,a=[],l=[];let d=0,u=0;const g=(t,n,r)=>{const o=u;return e.stream.appendToolUse(t,n,r,"streaming"),u+=1,o},m=()=>(d+=1,`tool_${e.requestId}_${d}`),c=e=>{const t=a.indexOf(e);t>=0&&a.splice(t,1)},p=e=>{const t=e?a.filter(t=>s.get(t)?.name===e):a;return 1===t.length?t[0]:void 0},f=e=>{const t=l.map((e,t)=>({hint:e,index:t})).filter(({hint:t})=>!e||t.name===e);if(1!==t.length)return;const[{hint:n,index:r}]=t;return l.splice(r,1),n},h=(e,t)=>{i.set(e,t)},I=e=>i.get(e)??e,y=e=>null!=e&&("object"!=typeof e||(Array.isArray(e)?e.length>0:Object.keys(e).length>0)),v=(t,n,r,o)=>{const i=s.get(t);if(i){const s="tool"===i.name&&"tool"!==n?n:i.name,l=!y(i.arguments)&&y(r)?r:i.arguments;return s===i.name&&l===i.arguments||(e.stream.replacePart(i.partIndex,{type:"tool_use",id:i.id,name:s,arguments:l,state:i.state}),i.name=s,i.arguments=l),o&&!a.includes(t)&&"streaming"===i.state&&a.push(t),i}const l={id:t,name:n,arguments:r,partIndex:g(t,n,r),state:"streaming",itemEventObserved:!1,toolStartObserved:!1};return s.set(t,l),o&&a.push(t),l},$=t=>{"done"!==t.state&&(e.stream.replacePart(t.partIndex,{type:"tool_use",id:t.id,name:t.name,arguments:t.arguments,state:"done"}),t.state="done")},x=e=>{if(null===e||"object"!=typeof e)return JSON.stringify(e)??String(e);if(Array.isArray(e))return`[${e.map(x).join(",")}]`;return`{${Object.entries(e).sort(([e],[t])=>e.localeCompare(t)).map(([e,t])=>`${JSON.stringify(e)}:${x(t)}`).join(",")}}`},b=(e,t,n)=>{const r=i.get(e);if(r)return v(r,t??"tool",n??{},!1);if(s.has(e))return v(e,t??"tool",n??{},!1);const o=p(t);return o?(h(e,o),v(o,t??"tool",n??{},!1)):v(e,t??"tool",n??{},!1)},_=(e,t)=>{const n=i.get(e);if(n)return v(n,t,{},!1);if(s.has(e))return v(e,t,{},!1);const r=(e=>{const t=a.filter(t=>{const n=s.get(t);return!(void 0===n||e&&n.name!==e||n.itemEventObserved)});return 1===t.length?t[0]:void 0})(t);if(r)return h(e,r),v(r,t,{},!1);const o=f(t);return v(e,t,o?.arguments??{},!1)},k={tool:0,block:0,final:0};let S=!1;const R=(t,n)=>{e.callbackQueue.enqueueSync(()=>{if((t=>e.abortSignal.aborted?(e.logger.debug?.(`[im-sink] ${t} ignored after abort requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):e.stream.isTerminated?(e.logger.debug?.(`[im-sink] ${t} ignored after stream_terminated requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):!S||(e.logger.debug?.(`[im-sink] ${t} ignored after mark_complete requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1))(t))try{n()}catch(n){const r=n instanceof Error?n.message:String(n);e.logger.warn?.(`[im-sink] ${t} mapping failed requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId} error=${r}`)}})};return{dispatcher:{sendToolResult:t=>(R("tool_progress",()=>{const n=resolveRuntimeToolCallId(t),r=resolveRuntimeToolName(t),o=n?void 0:r?p(r):p(),i=n||o?void 0:f(r);if(!n&&!o&&!i)return void e.logger.debug?.(`[im-sink] ambiguous id-less tool progress ignored requestId=${e.requestId} channel=${e.channelId} tool=${r??"unknown"} pending=${a.length}`);const l=n?b(n,r,resolveRuntimeToolArguments(t)):v(o??m(),r??s.get(o??"")?.name??i.name,y(resolveRuntimeToolArguments(t))?resolveRuntimeToolArguments(t):i?.arguments??{},!1);k.tool+=1,((t,n,r)=>{const o=`${r?"1":"0"}:${x(n)}`;if(t.progressSignature===o)return;const s={type:"custom",kind:"tool_progress",data:{tool_use_id:t.id,content:n,...r?{is_error:!0}:{}}};if(void 0===t.progressPartIndex){const n=u;return e.stream.addPart(s),t.progressPartIndex=n,t.progressSignature=o,void(u+=1)}e.stream.replacePart(t.progressPartIndex,s),t.progressSignature=o})(l,(e=>{const t=[e.mediaUrl,...e.mediaUrls??[]].filter(e=>isNonEmptyString(e));return isNonEmptyString(e.text)&&t.length>0?{text:e.text,media_urls:t}:isNonEmptyString(e.text)?e.text:1===t.length?t[0]:t.length>1?{media_urls:t}:!0===e.isError?"Tool execution failed.":""})(t),!0===t.isError)}),!0),sendBlockReply:n=>(R("block_reply",()=>{k.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(R("final_reply",()=>{k.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...k}),markComplete:t=>{if(1===l.length){const e=l.shift();v(m(),e.name,e.arguments,!1)}for(const n of s.values())try{"success"===t&&$(n),c(n.id)}catch(t){const r=t instanceof Error?t.message:String(t);e.logger.warn?.(`[im-sink] tool finalization failed requestId=${e.requestId} toolUseId=${n.id} error=${r}`)}l.length=0,S=!0}},replyOptions:{...e.runId?{runId:e.runId}:{},abortSignal:e.abortSignal,onAgentRunStart:t=>{e.onAgentRunStart?.(t),e.logger.info?.(`[im-sink] run_start requestId=${e.requestId} runId=${t} channel=${e.channelId} in_reply_to=${e.inboundMessageId} origin=${e.origin??"im"}`)},onPartialReply:n=>{R("partial",()=>{const r=extractBodyUpdate(t,n.text);switch(r.kind){case"append":e.stream.appendBody(r.text);break;case"replace":e.stream.setBody(r.text)}})},onReasoningStream:t=>{R("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const s=t.text;""===o&&""!==r&&(n.appendBody("\n"),r+="\n"),s!==o&&(s.startsWith(o)?(n.appendBody(s.slice(o.length)),o=s):o.startsWith(s)||(n.setBody(r+s),o=s))})},onReasoningEnd:()=>{R("reasoning_end",()=>{var t;""!==o&&(t=o,e.stream.appendThinking(t,"done"),u+=1,r+=o,o="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{R("tool_start",()=>{const n="string"==typeof t.phase?t.phase:"start",r=resolveRuntimeToolName(t)??"tool",o=resolveRuntimeToolCallId(t);if("update"===n){if(o){const e=I(o);s.has(e)&&v(e,r,resolveRuntimeToolArguments(t),!0)}return}const i=resolveRuntimeToolArguments(t);if(o){const e=b(o,r,i);return e.toolStartObserved=!0,void(a.includes(e.id)||"streaming"!==e.state||a.push(e.id))}const d=(u=r,a.map(e=>s.get(e)).filter(e=>void 0!==e&&(!u||e.name===u)&&e.itemEventObserved));var u;if(1===d.length){return void(v(d[0].id,r,i,!0).toolStartObserved=!0)}d.length>1?e.logger.debug?.(`[im-sink] ambiguous id-less tool start ignored requestId=${e.requestId} channel=${e.channelId} tool=${r} candidates=${d.length}`):l.push({name:r,arguments:i})})},onItemEvent:e=>{R("item_event",()=>{const t="string"==typeof e.kind?e.kind.toLowerCase():"";if(t&&"tool"!==t)return;const n="string"==typeof e.phase?e.phase.toLowerCase():"update",r=resolveRuntimeToolCallId(e)??("string"==typeof e.itemId&&e.itemId.trim()?e.itemId.trim():void 0),o=resolveRuntimeToolName(e)??("string"==typeof e.title&&e.title.trim()?e.title.trim():void 0);if(!r||!o)return;if("update"===n){const e=I(r),t=s.get(e);return void(t&&(v(t.id,o,{},!0).itemEventObserved=!0))}const i=_(r,o);i.itemEventObserved=!0,a.includes(i.id)||"streaming"!==i.state||a.push(i.id)})}}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readFileSync}from"node:fs";import{join}from"node:path";export const DEFAULT_RUNTIME_RESTART_DRAIN_MS=1e3;const DEFAULT_POD_AGENT_DIR="/tmp/agent";export function resolveSupervisorPidFile(e=process.env){const r=e.POD_AGENT_DIR?.trim()||"/tmp/agent";return join(r,"supervisor.pid")}const formatError=e=>e instanceof Error?e.message:String(e);export class RuntimeRestartScheduler{drainMs;supervisorPidFile;currentPid;readSupervisorPid;signalProcess;exitProcess;setTimer;scheduled=!1;constructor(e={}){this.drainMs=e.drainMs??1e3,this.supervisorPidFile=e.supervisorPidFile??resolveSupervisorPidFile(),this.currentPid=e.currentPid??process.pid,this.readSupervisorPid=e.readSupervisorPid??(e=>readFileSync(e,"utf8")),this.signalProcess=e.signalProcess??((e,r)=>process.kill(e,r)),this.exitProcess=e.exitProcess??(e=>process.exit(e)),this.setTimer=e.setTimer??((e,r)=>setTimeout(e,r))}schedule(e,r){return this.scheduled?(r.debug?.(`[runtime-restart] restart already scheduled; deduplicated sessionKey=${e.sessionKey} operationId=${e.operationId}`),!1):(this.scheduled=!0,r.error?.(`[runtime-restart] gateway owner restart scheduled after terminal drain drain_ms=${this.drainMs} sessionKey=${e.sessionKey} operationId=${e.operationId} reason=${e.reason}`),this.setTimer(()=>this.restartGatewayOwner(e,r),this.drainMs),!0)}restartGatewayOwner(e,r){try{const s=this.parseSupervisorPid(this.readSupervisorPid(this.supervisorPidFile));return this.signalProcess(s,0),this.signalProcess(s,"SIGHUP"),void r.error?.(`[runtime-restart] sent SIGHUP to gateway supervisor pid=${s} pid_file=${this.supervisorPidFile} sessionKey=${e.sessionKey} operationId=${e.operationId}`)}catch(e){r.warn?.(`[runtime-restart] supervisor restart unavailable; falling back to process.exit(1) pid_file=${this.supervisorPidFile} error=${formatError(e)}`)}this.exitProcess(1)}parseSupervisorPid(e){const r=e.trim();if(!/^[1-9]\d*$/.test(r))throw new Error("invalid supervisor pid file");const s=Number(r);if(!Number.isSafeInteger(s)||s<=1)throw new Error("invalid supervisor pid");if(s===this.currentPid)throw new Error("supervisor pid points to current process");return s}}const processRuntimeRestartScheduler=new RuntimeRestartScheduler;export function scheduleProcessRuntimeRestart(e,r){return processRuntimeRestartScheduler.schedule(e,r)}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{DEFAULT_RUNTIME_RESTART_DRAIN_MS,resolveSupervisorPidFile,RuntimeRestartScheduler}from"./runtime-restart-scheduler.js";const recovery=(e,t)=>({code:"session_recovery_required",sessionKey:e,operationId:t,reason:"quiescence_timeout",surface:"message_service"});describe("RuntimeRestartScheduler",()=>{it("resolves the supervisor contract from POD_AGENT_DIR with /tmp/agent as default",()=>{expect(resolveSupervisorPidFile({})).toBe("/tmp/agent/supervisor.pid"),expect(resolveSupervisorPidFile({POD_AGENT_DIR:"/run/custom-agent"})).toBe("/run/custom-agent/supervisor.pid")}),it("waits for the terminal drain, then SIGHUPs the pid-file supervisor once",()=>{let e;const t=vi.fn((t,r)=>(e=t,1)),r=vi.fn(()=>"4242\n"),i=vi.fn(),s=vi.fn(),o=new RuntimeRestartScheduler({supervisorPidFile:"/tmp/agent/supervisor.pid",currentPid:9e3,readSupervisorPid:r,signalProcess:i,exitProcess:s,setTimer:t});expect(o.schedule(recovery("agent:main:a","op-a"),{})).toBe(!0),expect(o.schedule(recovery("agent:main:b","op-b"),{})).toBe(!1),expect(t).toHaveBeenCalledTimes(1),expect(t).toHaveBeenCalledWith(expect.any(Function),DEFAULT_RUNTIME_RESTART_DRAIN_MS),expect(r).not.toHaveBeenCalled(),expect(i).not.toHaveBeenCalled(),expect(s).not.toHaveBeenCalled(),e(),expect(r).toHaveBeenCalledWith("/tmp/agent/supervisor.pid"),expect(i.mock.calls).toEqual([[4242,0],[4242,"SIGHUP"]]),expect(s).not.toHaveBeenCalled()}),it("falls back to process.exit(1) only after the drain when the pid file is unusable",()=>{let e;const t=vi.fn(),r=vi.fn();new RuntimeRestartScheduler({supervisorPidFile:"/tmp/agent/supervisor.pid",readSupervisorPid:()=>{throw new Error("ENOENT")},signalProcess:t,exitProcess:r,setTimer:t=>(e=t,1)}).schedule(recovery("agent:main:fallback","op-fallback"),{}),expect(r).not.toHaveBeenCalled(),e(),expect(t).not.toHaveBeenCalled(),expect(r).toHaveBeenCalledOnce(),expect(r).toHaveBeenCalledWith(1)}),it("falls back to process.exit(1) when the supervisor cannot accept SIGHUP",()=>{let e;const t=vi.fn((e,t)=>{if("SIGHUP"===t)throw new Error("EPERM")}),r=vi.fn();new RuntimeRestartScheduler({currentPid:9e3,readSupervisorPid:()=>"4242\n",signalProcess:t,exitProcess:r,setTimer:t=>(e=t,1)}).schedule(recovery("agent:main:signal-fallback","op-signal"),{}),e(),expect(t.mock.calls).toEqual([[4242,0],[4242,"SIGHUP"]]),expect(r).toHaveBeenCalledOnce(),expect(r).toHaveBeenCalledWith(1)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SESSION_RECOVERY_REQUIRED_CODE="session_recovery_required";export const SESSION_RECOVERY_REQUIRED_MESSAGE="The session could not be proven safe for another write. Recovery is required.";const DEFAULT_OBSERVATION_TIMEOUT_MS=13e4,DEFAULT_CANCELLATION_TERMINAL_OBSERVATION_TIMEOUT_MS=3e4;export class SessionMutationCoordinator{gate;quiescence;logger;states=new Map;observationTimeoutMs;cancellationTerminalObservationTimeoutMs;setTimer;clearTimer;constructor(e,r,t
|
|
1
|
+
export const SESSION_RECOVERY_REQUIRED_CODE="session_recovery_required";export const SESSION_RECOVERY_REQUIRED_MESSAGE="The session could not be proven safe for another write. Recovery is required.";const DEFAULT_OBSERVATION_TIMEOUT_MS=13e4,DEFAULT_CANCELLATION_TERMINAL_OBSERVATION_TIMEOUT_MS=3e4;export class SessionMutationCoordinator{gate;quiescence;logger;states=new Map;observationTimeoutMs;cancellationTerminalObservationTimeoutMs;setTimer;clearTimer;onRecoveryRequired;constructor(e,r,o,t={}){this.gate=e,this.quiescence=r,this.logger=o,this.observationTimeoutMs=t.observationTimeoutMs??13e4,this.cancellationTerminalObservationTimeoutMs=t.cancellationTerminalObservationTimeoutMs??3e4,this.setTimer=t.setTimer??((e,r)=>{const o=setTimeout(e,r);return o.unref?.(),o}),this.clearTimer=t.clearTimer??(e=>clearTimeout(e)),this.onRecoveryRequired=t.onRecoveryRequired}tryStartInteractive(e,r){const o=this.gate.tryStartInteractive(e);return o.ok&&this.register(o.token,r,"attachable"),o}tryStartExclusive(e,r){const o=this.gate.tryStartInteractive(e);return o.ok&&this.register(o.token,r,"exclusive"),o}async acquireInternal(e){const r=await this.gate.acquireInternal(e);return this.register(r,"internal","attachable"),r}bindRun(e,r){if(!e||!r)return;const o=this.states.get(e.operationId);o&&"recovery_required"!==o.phase&&(o.runId=r)}attachOrStartExternal(e,r,o){const t=this.stateForSession(e.sessionKey);if(t)return t.recovery?{ok:!1,reason:"session_busy",recovery:t.recovery}:"exclusive"===t.attachmentPolicy?{ok:!1,reason:"session_busy"}:t.runId&&o&&t.runId!==o?{ok:!1,reason:"run_conflict",recovery:this.fence(t,"run_conflict")}:(!t.runId&&o&&(t.runId=o),{ok:!0,ownership:"observer",ownerOperationId:t.token.operationId,attached:!0});const s=this.tryStartInteractive(e,r);return s.ok?(o&&this.bindRun(s.token,o),{ok:!0,ownership:"owner",token:s.token,attached:!1}):{ok:!1,reason:s.reason}}requestCancel(e,r){const o=this.requireState(e);if(o.recovery)return Promise.resolve({ok:!1,recovery:o.recovery});if(o.cancelPromise)return o.cancelPromise;if(o.terminalKind||o.convergencePromise)return Promise.resolve({ok:!1,reason:"terminal_already_observed"});o.phase="cancelling",o.cancelObservationPromise=new Promise(e=>{o.resolveCancelObservation=e});const t=r.chatAbort(e.sessionKey,o.runId).then(r=>r.aborted?(o.cancelAcknowledged=!0,o.terminalKind?this.resolveTerminalObserved(o):o.recovery||(o.cancelObservationTimer=this.setTimer(()=>{const e=this.fence(o,"terminal_not_observed");o.resolveCancelObservation?.({ok:!1,recovery:e}),o.resolveCancelObservation=void 0},this.cancellationTerminalObservationTimeoutMs)),this.logger.debug?.(`[session-mutation] cancel acknowledged sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""}`),{ok:!0,acknowledged:!0,terminalObservation:o.cancelObservationPromise}):(this.logger.error?.(`[session-mutation] cancel not acknowledged sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""} returnedRunIds=${r.runIds.join(",")}`),{ok:!1,recovery:this.fence(o,"abort_not_acknowledged")})).catch(r=>(this.logger.error?.(`[session-mutation] cancel transport failed sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""} error=${r instanceof Error?r.message:String(r)}`),{ok:!1,recovery:this.fence(o,"abort_failed")}));return o.cancelPromise=t,t}requestCancelForSession(e,r){const o=this.stateForSession(e);return o?"exclusive"===o.attachmentPolicy?Promise.resolve({ok:!1,reason:"not_cancellable"}):this.requestCancel(o.token,r):Promise.resolve({ok:!1,reason:"no_active_mutation"})}converge(e,r){const o=this.requireState(e);return o.convergencePromise||(o.terminalKind=r,this.resolveTerminalObserved(o),o.convergencePromise=this.proveConvergence(o)),o.convergencePromise}release(e){const r=this.states.get(e.operationId);r&&r.token.sessionKey===e.sessionKey&&"ready_to_release"===r.phase&&(this.clearCancellationTimer(r),this.gate.release(r.token),this.states.delete(e.operationId))}releaseBeforeRuntime(e){if(!e)return;const r=this.states.get(e.operationId);r?.recovery||(r&&this.clearCancellationTimer(r),this.gate.release(e),this.states.delete(e.operationId))}requireRecovery(e,r,o){const t=this.requireState(e);return o&&(t.terminalKind=o),this.fence(t,r)}snapshot(e){for(const r of this.states.values())if(r.token.sessionKey===e)return{sessionKey:e,operationId:r.token.operationId,surface:r.surface,attachmentPolicy:r.attachmentPolicy,phase:r.phase,...r.runId?{runId:r.runId}:{},...r.terminalKind?{terminalKind:r.terminalKind}:{},cancelAcknowledged:r.cancelAcknowledged,...r.recovery?{recovery:r.recovery}:{}}}gateSnapshot(e){return this.gate.snapshot(e)}fenceActive(e){for(const r of this.states.values())r.recovery||this.fence(r,e)}register(e,r,o){this.states.set(e.operationId,{token:e,surface:r,attachmentPolicy:o,phase:"active",cancelAcknowledged:!1})}requireState(e){const r=this.states.get(e.operationId);if(!r||r.token.sessionKey!==e.sessionKey)throw new Error("session_mutation_not_registered");return r}stateForSession(e){for(const r of this.states.values())if(r.token.sessionKey===e)return r}async proveConvergence(e){if(e.recovery)return{ok:!1,recovery:e.recovery};if(!e.terminalKind)return{ok:!1,recovery:this.fence(e,"terminal_not_observed")};if(e.cancelPromise){const r=await e.cancelPromise;if(!r.ok)return"recovery"in r?r:{ok:!1,recovery:this.fence(e,"terminal_not_observed")}}if(e.recovery)return{ok:!1,recovery:e.recovery};let r;e.phase="awaiting_quiescence";try{r=await this.quiescence.waitForQuiescence(e.token.sessionKey,this.observationTimeoutMs)}catch{return e.recovery?{ok:!1,recovery:e.recovery}:{ok:!1,recovery:this.fence(e,"quiescence_failed")}}if(e.recovery)return{ok:!1,recovery:e.recovery};if(!r.quiescent){const o="session_unresolved"===r.reason?"session_unresolved":"quiescence_timeout";return{ok:!1,recovery:this.fence(e,o,r)}}return e.phase="ready_to_release",this.logger.debug?.(`[session-mutation] convergence proven sessionKey=${e.token.sessionKey} operationId=${e.token.operationId} terminal=${e.terminalKind} elapsed_ms=${r.elapsedMs}`),{ok:!0,permit:{sessionKey:e.token.sessionKey,operationId:e.token.operationId}}}fence(e,r,o){if(e.recovery)return e.recovery;const t={code:"session_recovery_required",sessionKey:e.token.sessionKey,operationId:e.token.operationId,reason:r,surface:e.surface,...e.terminalKind?{terminalKind:e.terminalKind}:{},...o?{quiescence:o}:{}};e.recovery=t,e.phase="recovery_required",this.clearCancellationTimer(e),e.resolveCancelObservation?.({ok:!1,recovery:t}),e.resolveCancelObservation=void 0,this.logger.error?.(`[session-mutation] recovery required sessionKey=${t.sessionKey} operationId=${t.operationId} reason=${t.reason} surface=${t.surface}`);try{this.onRecoveryRequired?.(t)}catch(e){this.logger.error?.(`[session-mutation] recovery restart scheduling failed sessionKey=${t.sessionKey} operationId=${t.operationId} error=${e instanceof Error?e.message:String(e)}`)}return t}resolveTerminalObserved(e){e.recovery||(this.clearCancellationTimer(e),e.resolveCancelObservation?.({ok:!0,terminalObserved:!0}),e.resolveCancelObservation=void 0)}clearCancellationTimer(e){e.cancelObservationTimer&&(this.clearTimer(e.cancelObservationTimer),e.cancelObservationTimer=void 0)}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"./session-mutation-coordinator.js";const deferred=()=>{let e;return{promise:new Promise(t=>{e=t}),resolve:e}};describe("SessionMutationCoordinator",()=>{it("requires both awaited cancel acknowledgement and terminal quiescence",async()=>{const e=new SessionWriteGate,t=deferred(),n=deferred(),a=new SessionMutationCoordinator(e,{waitForQuiescence:vi.fn(async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:5}))},{}),o=a.tryStartInteractive({sessionKey:"agent:main:one"},"acp");if(!o.ok)throw new Error("expected lease");a.bindRun(o.token,"run-1");const s=a.requestCancel(o.token,{chatAbort:vi.fn(async()=>{await n.promise})}),r=a.converge(o.token,"cancelled");await Promise.resolve(),expect(a.snapshot("agent:main:one")?.cancelAcknowledged).toBe(!1),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),n.resolve(),await expect(s).resolves.toMatchObject({ok:!0,acknowledged:!0}),expect(a.snapshot("agent:main:one")?.phase).toBe("awaiting_quiescence"),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),t.resolve();const i=await r;expect(i.ok).toBe(!0),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),i.ok&&a.release(i.permit),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!0)}),it("fails closed and exposes recovery-required when quiescence times out",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!1,reason:"timeout",elapsedMs:20})},{},{observationTimeoutMs:20}),n=t.tryStartInteractive({sessionKey:"agent:main:fenced"},"message_service");if(!n.ok)throw new Error("expected lease");await expect(t.converge(n.token,"timeout")).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"quiescence_timeout",terminalKind:"timeout"}}),expect(t.snapshot("agent:main:fenced")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:fenced"}).ok).toBe(!1)}),it("fences as quiescence_failed when the observer rejects",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>{throw new Error("observer failed")}},{}),n=t.tryStartInteractive({sessionKey:"agent:main:observer-failed"},"acp");if(!n.ok)throw new Error("expected lease");await expect(t.converge(n.token,"error")).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"quiescence_failed"}}),expect(t.snapshot("agent:main:observer-failed")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:observer-failed"}).ok).toBe(!1)}),it("blocks the same gateway session while allowing different sessions concurrently",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});expect(t.tryStartInteractive({sessionKey:"agent:main:a"},"acp").ok).toBe(!0),expect(t.tryStartInteractive({sessionKey:"agent:main:a"},"message_service").ok).toBe(!1),expect(t.tryStartInteractive({sessionKey:"agent:main:b"},"message_service").ok).toBe(!0)}),it("releases a normally completed mutation only after the caller commits its permit",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartInteractive({sessionKey:"agent:main:normal"},"acp");if(!n.ok)throw new Error("expected lease");const a=await t.converge(n.token,"completed");expect(a.ok).toBe(!0),expect(e.snapshot("agent:main:normal").active).toBeDefined(),a.ok&&t.release(a.permit),expect(e.snapshot("agent:main:normal").active).toBeUndefined()}),it.each(["acp","message_service"])("applies identical cancel convergence for the %s surface",async e=>{const t=new SessionWriteGate,n=vi.fn().mockResolvedValue(void 0),a=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),o=a.tryStartInteractive({sessionKey:`agent:main:${e}`},e);if(!o.ok)throw new Error("expected lease");a.bindRun(o.token,"run-shared-contract"),await expect(a.requestCancel(o.token,{chatAbort:n})).resolves.toMatchObject({ok:!0,acknowledged:!0});const s=await a.converge(o.token,"cancelled");expect(s.ok).toBe(!0),expect(n).toHaveBeenCalledWith(`agent:main:${e}`,"run-shared-contract"),expect(a.snapshot(`agent:main:${e}`)).toMatchObject({surface:e,phase:"ready_to_release",terminalKind:"cancelled",cancelAcknowledged:!0})}),it("requests cancellation from the current owner by session key",async()=>{const e=new SessionWriteGate,t=vi.fn().mockResolvedValue(void 0),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),a=n.tryStartInteractive({sessionKey:"agent:main:cancel-by-session"},"message_service");if(!a.ok)throw new Error("expected owner");n.bindRun(a.token,"run-owner"),await expect(n.requestCancelForSession("agent:main:cancel-by-session",{chatAbort:t})).resolves.toMatchObject({ok:!0,acknowledged:!0}),expect(t).toHaveBeenCalledWith("agent:main:cancel-by-session","run-owner"),expect(n.snapshot("agent:main:cancel-by-session")).toMatchObject({operationId:a.token.operationId,phase:"cancelling",cancelAcknowledged:!0});const o=await n.converge(a.token,"cancelled");o.ok&&n.release(o.permit)}),it("reports no active mutation when cancellation has no owner",async()=>{const e=new SessionMutationCoordinator(new SessionWriteGate,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});await expect(e.requestCancelForSession("agent:main:no-owner",{chatAbort:vi.fn()})).resolves.toEqual({ok:!1,reason:"no_active_mutation"})}),it("fences and resolves recovery when cancel ACK is not followed by a terminal",async()=>{const e=new SessionWriteGate;let t;const n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:25,setTimer:e=>(t=e,{unref:()=>{}}),clearTimer:()=>{}}),a=n.tryStartInteractive({sessionKey:"agent:main:no-terminal"},"acp");if(!a.ok)throw new Error("expected lease");const o=await n.requestCancel(a.token,{chatAbort:async()=>{}});if(!o.ok)throw new Error("expected cancel ACK");t(),await expect(o.terminalObservation).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"terminal_not_observed"}}),expect(n.snapshot("agent:main:no-terminal")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:no-terminal"}).ok).toBe(!1)}),it("clears the cancel observation timer when terminal arrives",async()=>{const e=new SessionWriteGate,t={unref:()=>{}},n=vi.fn(),a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:25,setTimer:()=>t,clearTimer:n}),o=a.tryStartInteractive({sessionKey:"agent:main:terminal"},"message_service");if(!o.ok)throw new Error("expected lease");const s=await a.requestCancel(o.token,{chatAbort:async()=>{}});if(!s.ok)throw new Error("expected cancel ACK");const r=a.converge(o.token,"cancelled");await expect(s.terminalObservation).resolves.toEqual({ok:!0,terminalObserved:!0}),expect(n).toHaveBeenCalledWith(t),await expect(r).resolves.toMatchObject({ok:!0})}),it("rejects late cancel after terminal convergence has started",async()=>{const e=new SessionWriteGate,t=deferred(),n=vi.fn().mockResolvedValue(void 0),a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),o=a.tryStartInteractive({sessionKey:"agent:main:late-cancel"},"acp");if(!o.ok)throw new Error("expected lease");const s=a.converge(o.token,"completed");await expect(a.requestCancel(o.token,{chatAbort:n})).resolves.toEqual({ok:!1,reason:"terminal_already_observed"}),expect(n).not.toHaveBeenCalled(),t.resolve(),await expect(s).resolves.toMatchObject({ok:!0})}),it("keeps disconnect recovery fenced when quiescence resolves concurrently",async()=>{const e=new SessionWriteGate,t=deferred(),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),a=n.tryStartInteractive({sessionKey:"agent:main:disconnect-race"},"acp");if(!a.ok)throw new Error("expected lease");const o=n.converge(a.token,"completed");await Promise.resolve(),n.fenceActive("gateway_disconnected"),t.resolve(),await expect(o).resolves.toMatchObject({ok:!1,recovery:{reason:"gateway_disconnected"}}),expect(n.snapshot("agent:main:disconnect-race")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:disconnect-race"}).ok).toBe(!1)}),it.each([void 0,"run-shared"])("attaches an external run to an existing Message Service owner (owner run=%s)",e=>{const t=new SessionWriteGate,n=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),a=n.tryStartInteractive({sessionKey:"agent:main:shared-owner"},"message_service");if(!a.ok)throw new Error("expected owner");e&&n.bindRun(a.token,e);const o=n.attachOrStartExternal({sessionKey:"agent:main:shared-owner"},"acp","run-shared");expect(o).toEqual({ok:!0,ownership:"observer",ownerOperationId:a.token.operationId,attached:!0}),expect(n.snapshot("agent:main:shared-owner")?.runId).toBe("run-shared")}),it("keeps the owner lease active when an attached observer reaches terminal first",async()=>{const e=new SessionWriteGate,t=vi.fn(async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})),n=new SessionMutationCoordinator(e,{waitForQuiescence:t},{}),a=n.attachOrStartExternal({sessionKey:"agent:main:observer-terminal"},"message_service","run-shared");if(!a.ok||"owner"!==a.ownership)throw new Error("expected owner");const o=n.attachOrStartExternal({sessionKey:"agent:main:observer-terminal"},"acp","run-shared");expect(o).toMatchObject({ok:!0,ownership:"observer",attached:!0}),expect(n.snapshot("agent:main:observer-terminal")?.phase).toBe("active"),expect(e.snapshot("agent:main:observer-terminal").active?.operationId).toBe(a.token.operationId),expect(t).not.toHaveBeenCalled();const s=await n.converge(a.token,"completed");expect(s.ok).toBe(!0),s.ok&&n.release(s.permit),expect(e.snapshot("agent:main:observer-terminal").active).toBeUndefined()}),it("returns an owner token when no mutation exists",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}).attachOrStartExternal({sessionKey:"agent:main:new-external"},"acp","run-new");expect(t).toMatchObject({ok:!0,ownership:"owner",attached:!1,token:{sessionKey:"agent:main:new-external"}})}),it("rejects external attachment and cancellation without mutating an exclusive owner",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartExclusive({sessionKey:"agent:main:exclusive"},"acp");if(!n.ok)throw new Error("expected exclusive owner");expect(t.attachOrStartExternal({sessionKey:"agent:main:exclusive"},"message_service","run-external")).toEqual({ok:!1,reason:"session_busy"}),expect(t.snapshot("agent:main:exclusive")).toMatchObject({operationId:n.token.operationId,attachmentPolicy:"exclusive",phase:"active"}),expect(t.snapshot("agent:main:exclusive")?.runId).toBeUndefined(),expect(e.snapshot("agent:main:exclusive").active?.operationId).toBe(n.token.operationId);const a=vi.fn();await expect(t.requestCancelForSession("agent:main:exclusive",{chatAbort:a})).resolves.toEqual({ok:!1,reason:"not_cancellable"}),expect(a).not.toHaveBeenCalled(),expect(t.snapshot("agent:main:exclusive")).toMatchObject({operationId:n.token.operationId,attachmentPolicy:"exclusive",phase:"active"})}),it("fences an existing owner when an external run id conflicts",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartInteractive({sessionKey:"agent:main:conflict"},"message_service");if(!n.ok)throw new Error("expected owner");t.bindRun(n.token,"run-owner"),expect(t.attachOrStartExternal({sessionKey:"agent:main:conflict"},"acp","run-external")).toMatchObject({ok:!1,reason:"run_conflict",recovery:{reason:"run_conflict"}}),expect(t.snapshot("agent:main:conflict")?.phase).toBe("recovery_required")})});
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"./session-mutation-coordinator.js";const deferred=()=>{let e;return{promise:new Promise(t=>{e=t}),resolve:e}};describe("SessionMutationCoordinator",()=>{it("requires both awaited cancel acknowledgement and terminal quiescence",async()=>{const e=new SessionWriteGate,t=deferred(),n=deferred(),o=new SessionMutationCoordinator(e,{waitForQuiescence:vi.fn(async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:5}))},{}),a=o.tryStartInteractive({sessionKey:"agent:main:one"},"acp");if(!a.ok)throw new Error("expected lease");o.bindRun(a.token,"run-1");const s=o.requestCancel(a.token,{chatAbort:vi.fn(async()=>(await n.promise,{aborted:!0,runIds:["run-1"]}))}),r=o.converge(a.token,"cancelled");await Promise.resolve(),expect(o.snapshot("agent:main:one")?.cancelAcknowledged).toBe(!1),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),n.resolve(),await expect(s).resolves.toMatchObject({ok:!0,acknowledged:!0}),expect(o.snapshot("agent:main:one")?.phase).toBe("awaiting_quiescence"),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),t.resolve();const i=await r;expect(i.ok).toBe(!0),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),i.ok&&o.release(i.permit),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!0)}),it("fails closed and exposes recovery-required when quiescence times out",async()=>{const e=new SessionWriteGate,t=vi.fn(),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!1,reason:"timeout",elapsedMs:20})},{},{observationTimeoutMs:20,onRecoveryRequired:t}),o=n.tryStartInteractive({sessionKey:"agent:main:fenced"},"message_service");if(!o.ok)throw new Error("expected lease");await expect(n.converge(o.token,"timeout")).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"quiescence_timeout",terminalKind:"timeout"}}),expect(n.snapshot("agent:main:fenced")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:fenced"}).ok).toBe(!1),expect(t).toHaveBeenCalledOnce(),expect(t).toHaveBeenCalledWith(expect.objectContaining({reason:"quiescence_timeout",sessionKey:"agent:main:fenced"}))}),it("fences as quiescence_failed when the observer rejects",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>{throw new Error("observer failed")}},{}),n=t.tryStartInteractive({sessionKey:"agent:main:observer-failed"},"acp");if(!n.ok)throw new Error("expected lease");await expect(t.converge(n.token,"error")).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"quiescence_failed"}}),expect(t.snapshot("agent:main:observer-failed")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:observer-failed"}).ok).toBe(!1)}),it("blocks the same gateway session while allowing different sessions concurrently",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});expect(t.tryStartInteractive({sessionKey:"agent:main:a"},"acp").ok).toBe(!0),expect(t.tryStartInteractive({sessionKey:"agent:main:a"},"message_service").ok).toBe(!1),expect(t.tryStartInteractive({sessionKey:"agent:main:b"},"message_service").ok).toBe(!0)}),it("releases a normally completed mutation only after the caller commits its permit",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartInteractive({sessionKey:"agent:main:normal"},"acp");if(!n.ok)throw new Error("expected lease");const o=await t.converge(n.token,"completed");expect(o.ok).toBe(!0),expect(e.snapshot("agent:main:normal").active).toBeDefined(),o.ok&&t.release(o.permit),expect(e.snapshot("agent:main:normal").active).toBeUndefined()}),it.each(["acp","message_service"])("applies identical cancel convergence for the %s surface",async e=>{const t=new SessionWriteGate,n=vi.fn(),o=vi.fn().mockResolvedValue({aborted:!0,runIds:["run-shared-contract"]}),a=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:1})},{},{onRecoveryRequired:n}),s=a.tryStartInteractive({sessionKey:`agent:main:${e}`},e);if(!s.ok)throw new Error("expected lease");a.bindRun(s.token,"run-shared-contract"),await expect(a.requestCancel(s.token,{chatAbort:o})).resolves.toMatchObject({ok:!0,acknowledged:!0});const r=await a.converge(s.token,"cancelled");expect(r.ok).toBe(!0),expect(o).toHaveBeenCalledWith(`agent:main:${e}`,"run-shared-contract"),expect(a.snapshot(`agent:main:${e}`)).toMatchObject({surface:e,phase:"ready_to_release",terminalKind:"cancelled",cancelAcknowledged:!0}),expect(n).not.toHaveBeenCalled()}),it("fences aborted=false without marking a remote acknowledgement",async()=>{const e=new SessionWriteGate,t=vi.fn(),n=vi.fn(),o=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{error:n},{onRecoveryRequired:t}),a=o.tryStartInteractive({sessionKey:"agent:main:abort-missed"},"message_service");if(!a.ok)throw new Error("expected lease");o.bindRun(a.token,"run-missed");const s={chatAbort:vi.fn().mockResolvedValue({aborted:!1,runIds:[]})};await expect(o.requestCancel(a.token,s)).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"abort_not_acknowledged"}}),expect(o.snapshot("agent:main:abort-missed")).toMatchObject({phase:"recovery_required",cancelAcknowledged:!1,recovery:{reason:"abort_not_acknowledged"}}),expect(n).toHaveBeenCalledWith(expect.stringContaining("cancel not acknowledged")),expect(t).toHaveBeenCalledTimes(1),await expect(o.requestCancel(a.token,s)).resolves.toMatchObject({ok:!1,recovery:{reason:"abort_not_acknowledged"}}),expect(s.chatAbort).toHaveBeenCalledTimes(1),expect(t).toHaveBeenCalledTimes(1)}),it("requests cancellation from the current owner by session key",async()=>{const e=new SessionWriteGate,t=vi.fn().mockResolvedValue({aborted:!0,runIds:["run-owner"]}),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),o=n.tryStartInteractive({sessionKey:"agent:main:cancel-by-session"},"message_service");if(!o.ok)throw new Error("expected owner");n.bindRun(o.token,"run-owner"),await expect(n.requestCancelForSession("agent:main:cancel-by-session",{chatAbort:t})).resolves.toMatchObject({ok:!0,acknowledged:!0}),expect(t).toHaveBeenCalledWith("agent:main:cancel-by-session","run-owner"),expect(n.snapshot("agent:main:cancel-by-session")).toMatchObject({operationId:o.token.operationId,phase:"cancelling",cancelAcknowledged:!0});const a=await n.converge(o.token,"cancelled");a.ok&&n.release(a.permit)}),it("reports no active mutation when cancellation has no owner",async()=>{const e=new SessionMutationCoordinator(new SessionWriteGate,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});await expect(e.requestCancelForSession("agent:main:no-owner",{chatAbort:vi.fn()})).resolves.toEqual({ok:!1,reason:"no_active_mutation"})}),it("fences and resolves recovery when cancel ACK is not followed by a terminal",async()=>{const e=new SessionWriteGate;let t;const n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:25,setTimer:e=>(t=e,{unref:()=>{}}),clearTimer:()=>{}}),o=n.tryStartInteractive({sessionKey:"agent:main:no-terminal"},"acp");if(!o.ok)throw new Error("expected lease");const a=await n.requestCancel(o.token,{chatAbort:async()=>({aborted:!0,runIds:[]})});if(!a.ok)throw new Error("expected cancel ACK");t(),await expect(a.terminalObservation).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"terminal_not_observed"}}),expect(n.snapshot("agent:main:no-terminal")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:no-terminal"}).ok).toBe(!1)}),it("clears the cancel observation timer when terminal arrives",async()=>{const e=new SessionWriteGate,t={unref:()=>{}},n=vi.fn(),o=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:25,setTimer:()=>t,clearTimer:n}),a=o.tryStartInteractive({sessionKey:"agent:main:terminal"},"message_service");if(!a.ok)throw new Error("expected lease");const s=await o.requestCancel(a.token,{chatAbort:async()=>({aborted:!0,runIds:[]})});if(!s.ok)throw new Error("expected cancel ACK");const r=o.converge(a.token,"cancelled");await expect(s.terminalObservation).resolves.toEqual({ok:!0,terminalObserved:!0}),expect(n).toHaveBeenCalledWith(t),await expect(r).resolves.toMatchObject({ok:!0})}),it("rejects late cancel after terminal convergence has started",async()=>{const e=new SessionWriteGate,t=deferred(),n=vi.fn().mockResolvedValue({aborted:!0,runIds:[]}),o=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),a=o.tryStartInteractive({sessionKey:"agent:main:late-cancel"},"acp");if(!a.ok)throw new Error("expected lease");const s=o.converge(a.token,"completed");await expect(o.requestCancel(a.token,{chatAbort:n})).resolves.toEqual({ok:!1,reason:"terminal_already_observed"}),expect(n).not.toHaveBeenCalled(),t.resolve(),await expect(s).resolves.toMatchObject({ok:!0})}),it("keeps disconnect recovery fenced when quiescence resolves concurrently",async()=>{const e=new SessionWriteGate,t=deferred(),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),o=n.tryStartInteractive({sessionKey:"agent:main:disconnect-race"},"acp");if(!o.ok)throw new Error("expected lease");const a=n.converge(o.token,"completed");await Promise.resolve(),n.fenceActive("gateway_disconnected"),t.resolve(),await expect(a).resolves.toMatchObject({ok:!1,recovery:{reason:"gateway_disconnected"}}),expect(n.snapshot("agent:main:disconnect-race")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:disconnect-race"}).ok).toBe(!1)}),it.each([void 0,"run-shared"])("attaches an external run to an existing Message Service owner (owner run=%s)",e=>{const t=new SessionWriteGate,n=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),o=n.tryStartInteractive({sessionKey:"agent:main:shared-owner"},"message_service");if(!o.ok)throw new Error("expected owner");e&&n.bindRun(o.token,e);const a=n.attachOrStartExternal({sessionKey:"agent:main:shared-owner"},"acp","run-shared");expect(a).toEqual({ok:!0,ownership:"observer",ownerOperationId:o.token.operationId,attached:!0}),expect(n.snapshot("agent:main:shared-owner")?.runId).toBe("run-shared")}),it("keeps the owner lease active when an attached observer reaches terminal first",async()=>{const e=new SessionWriteGate,t=vi.fn(async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})),n=new SessionMutationCoordinator(e,{waitForQuiescence:t},{}),o=n.attachOrStartExternal({sessionKey:"agent:main:observer-terminal"},"message_service","run-shared");if(!o.ok||"owner"!==o.ownership)throw new Error("expected owner");const a=n.attachOrStartExternal({sessionKey:"agent:main:observer-terminal"},"acp","run-shared");expect(a).toMatchObject({ok:!0,ownership:"observer",attached:!0}),expect(n.snapshot("agent:main:observer-terminal")?.phase).toBe("active"),expect(e.snapshot("agent:main:observer-terminal").active?.operationId).toBe(o.token.operationId),expect(t).not.toHaveBeenCalled();const s=await n.converge(o.token,"completed");expect(s.ok).toBe(!0),s.ok&&n.release(s.permit),expect(e.snapshot("agent:main:observer-terminal").active).toBeUndefined()}),it("returns an owner token when no mutation exists",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}).attachOrStartExternal({sessionKey:"agent:main:new-external"},"acp","run-new");expect(t).toMatchObject({ok:!0,ownership:"owner",attached:!1,token:{sessionKey:"agent:main:new-external"}})}),it("rejects external attachment and cancellation without mutating an exclusive owner",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartExclusive({sessionKey:"agent:main:exclusive"},"acp");if(!n.ok)throw new Error("expected exclusive owner");expect(t.attachOrStartExternal({sessionKey:"agent:main:exclusive"},"message_service","run-external")).toEqual({ok:!1,reason:"session_busy"}),expect(t.snapshot("agent:main:exclusive")).toMatchObject({operationId:n.token.operationId,attachmentPolicy:"exclusive",phase:"active"}),expect(t.snapshot("agent:main:exclusive")?.runId).toBeUndefined(),expect(e.snapshot("agent:main:exclusive").active?.operationId).toBe(n.token.operationId);const o=vi.fn();await expect(t.requestCancelForSession("agent:main:exclusive",{chatAbort:o})).resolves.toEqual({ok:!1,reason:"not_cancellable"}),expect(o).not.toHaveBeenCalled(),expect(t.snapshot("agent:main:exclusive")).toMatchObject({operationId:n.token.operationId,attachmentPolicy:"exclusive",phase:"active"})}),it("fences an existing owner when an external run id conflicts",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartInteractive({sessionKey:"agent:main:conflict"},"message_service");if(!n.ok)throw new Error("expected owner");t.bindRun(n.token,"run-owner"),expect(t.attachOrStartExternal({sessionKey:"agent:main:conflict"},"acp","run-external")).toMatchObject({ok:!1,reason:"run_conflict",recovery:{reason:"run_conflict"}}),expect(t.snapshot("agent:main:conflict")?.phase).toBe("recovery_required")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{PromptRunStore}from"../runtime/prompt-run-store.js";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"../runtime/session-mutation-coordinator.js";export const deferred=()=>{let e,t;return{promise:new Promise((s,n)=>{e=s,t=n}),resolve:e,reject:t}};export class TestMessageStream{body="";isTerminated=!1;terminal;async opened(){return{id:"test-envelope",state:"streaming"}}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(){}replacePart(){}appendThinking(){}appendToolUse(){}appendToolResult(){}async finalize(e){return this.terminal={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"completed"}}async fail(e){return this.terminal={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"failed"}}async cancel(e){return this.terminal={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"test-envelope",state:"cancelled"}}}export class TestMessageClient{streams=[];startStreamCalls=[];messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,replyTo:e.replyTo,type:e.type});const t=new TestMessageStream;return this.streams.push(t),t}};asMessageClient(){return this}}export const createRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const s=t.replyOptions;if(!s?.abortSignal)throw new Error("test runtime expected an abort signal");return e({invocation:t,dispatcher:t.dispatcher,replyOptions:s,abortSignal:s.abortSignal})}}}});const createLogger=()=>({debug:()=>{},info:()=>{},warn:()=>{},error:()=>{}});export const createImDispatchFixture=e=>{const t=new TestMessageClient,s=[],n=new PromptRunStore,o=e?.sessionWriteGate??new SessionWriteGate,r=e?.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{client:t,events:s,promptRunStore:n,args:(e,o={})=>({runtime:e,client:t.asMessageClient(),logger:createLogger(),promptRunStore:n,sessionMutationCoordinator:r,abortTransport:{chatAbort:async()=>{}},origin:"im",onLifecycleEvent:e=>s.push(e),prompt:{channelId:"channel-test",inboundMessageId:"message-test",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"test prompt"}},...o})}};
|
|
1
|
+
import{PromptRunStore}from"../runtime/prompt-run-store.js";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"../runtime/session-mutation-coordinator.js";export const deferred=()=>{let e,t;return{promise:new Promise((s,n)=>{e=s,t=n}),resolve:e,reject:t}};export class TestMessageStream{body="";isTerminated=!1;terminal;async opened(){return{id:"test-envelope",state:"streaming"}}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(){}replacePart(){}appendThinking(){}appendToolUse(){}appendToolResult(){}async finalize(e){return this.terminal={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"completed"}}async fail(e){return this.terminal={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"failed"}}async cancel(e){return this.terminal={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"test-envelope",state:"cancelled"}}}export class TestMessageClient{streams=[];startStreamCalls=[];messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,replyTo:e.replyTo,type:e.type});const t=new TestMessageStream;return this.streams.push(t),t}};asMessageClient(){return this}}export const createRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const s=t.replyOptions;if(!s?.abortSignal)throw new Error("test runtime expected an abort signal");return e({invocation:t,dispatcher:t.dispatcher,replyOptions:s,abortSignal:s.abortSignal})}}}});const createLogger=()=>({debug:()=>{},info:()=>{},warn:()=>{},error:()=>{}});export const createImDispatchFixture=e=>{const t=new TestMessageClient,s=[],n=new PromptRunStore,o=e?.sessionWriteGate??new SessionWriteGate,r=e?.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{client:t,events:s,promptRunStore:n,args:(e,o={})=>({runtime:e,client:t.asMessageClient(),logger:createLogger(),promptRunStore:n,sessionMutationCoordinator:r,abortTransport:{chatAbort:async(e,t)=>({aborted:!0,runIds:t?[t]:[]})},origin:"im",onLifecycleEvent:e=>s.push(e),prompt:{channelId:"channel-test",inboundMessageId:"message-test",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"test prompt"}},...o})}};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beeos-claw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.61",
|
|
4
4
|
"description": "OpenClaw plugin that bridges the BeeOS platform with the local OpenClaw Gateway, providing file transfer, ACP bridge, and observability.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BeeOS <dev@beeos.ai>",
|