beeos-claw 0.5.61 → 0.5.63
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-session-mutation-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/im-inbound-handler.js +1 -1
- package/dist/im-inbound-handler.test.js +1 -1
- package/dist/im-runtime-dispatch.test.js +1 -1
- package/dist/runtime/session-mutation-coordinator.js +1 -1
- package/dist/runtime/session-mutation-coordinator.test.js +1 -1
- package/package.json +1 -1
|
@@ -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),
|
|
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),a={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}}},s={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:a,config:{gateway:{agentId:"main"},promptTimeoutMs:10,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5},logger:n,writeObs:vi.fn(),getGateway:()=>s,instanceId:"test-instance"});return"hook"===t&&(i.onRunComplete=()=>{throw new Error("hook failed")}),{bridge:i,state:o,transport:a,gateway:s,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),a=vi.fn();t.bridge.onRunEnded=a,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(a).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 a=await e.mutation.coordinator.converge(n.token,"cancelled");a.ok&&e.mutation.coordinator.release(a.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 a=t.mutation.coordinator.snapshot(n.gatewaySessionKey);expect(a).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(a),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(a),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("accepts cancellation when chat.abort transport fails and awaits terminal convergence",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.sendResult).toHaveBeenCalledWith("cancel-1",{}),expect(e.transport.sendError).not.toHaveBeenCalled(),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)).toMatchObject({phase:"cancelling",cancelAcknowledged:!1})})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID,createHash}from"node:crypto";import{mkdir,writeFile,readFile,rm,lstat,readlink,stat,readdir,rename,cp}from"node:fs/promises";import path from"node:path";import{join,resolve,relative,sep}from"node:path";import{homedir}from"node:os";import AdmZip from"adm-zip";import{sanitizePromptPayload}from"../message-sanitization/prompt-payload-sanitizer.js";import{previewSanitizedText,stripTransportMetadata}from"../message-sanitization/transport-metadata-sanitizer.js";import{isHeartbeatPromptText,isCronPromptText,extractCronJobId,classifyHeartbeatFilter}from"../message-sanitization/message-classifier.js";import{createCronOverridesStore}from"../cron-overrides.js";import{CRON_DURATION_FLOOR_MS}from"../config.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../delegation/mention-resolver.js";import{EXTERNAL_PROTOCOL_TAGS}from"./external-protocol-tags.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{extractBeeosUserTextFromHistoryContent}from"./beeos-user-envelope.js";import{loadHistory,loadHistoryPage,groupIntoTurns,splitUserReplayBlocks}from"./history-replay.js";import{classifyHistoricalAssistantTurnCompletion}from"./history-turn-completion-policy.js";import{LocalSessionHistory}from"./local-session-history.js";import{reconcileMissingArtifacts}from"./missing-artifacts-reconciler.js";import{buildResolutionPlan,resolveResources,applyResolutions}from"./prompt-resource-resolver.js";import{readEnrichmentCache,readUploadToolResult}from"../upload/result-cache.js";import{buildToolResultSessionContentBlocks}from"./tool-result-blocks.js";import{prefersChatFileArtifacts}from"./file-artifact-presenter.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"../runtime/agent-runtime-failure-classifier.js";import{SESSION_RECOVERY_REQUIRED_CODE,SESSION_RECOVERY_REQUIRED_MESSAGE}from"../runtime/session-mutation-coordinator.js";import{observeLifecyclePromise}from"../runtime/lifecycle-promise-observer.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole,recordToolCallTurnForUpdate}from"./gateway-events.js";import{emitToolResultProjectionForTurn}from"./tool-result-emitter.js";import{extractPersistedToolResultEnvelope,findPersistedToolResultEnvelopeByMessageId}from"./tool-result-transcript.js";import{projectPersistedToolResultForReplay}from"./tool-result-projection.js";import{McpService}from"./mcp-service.js";import{DEFAULT_PROMPT_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types/acp";import{parseAndValidateTemplatePackage,TemplatePackageError,MAX_ZIP_BYTES}from"./agent-template-package.js";const AGENT_VALID_ID_RE=/^[a-z0-9][a-z0-9_-]{0,63}$/i,AGENT_INVALID_CHARS_RE=/[^a-z0-9_-]+/g,AGENT_LEADING_DASH_RE=/^-+/,AGENT_TRAILING_DASH_RE=/-+$/,DEFAULT_AGENT_ID="main";export function normalizeAgentId(e){const t=(e??"").trim();return t?AGENT_VALID_ID_RE.test(t)?t.toLowerCase():t.toLowerCase().replace(AGENT_INVALID_CHARS_RE,"-").replace(AGENT_LEADING_DASH_RE,"").replace(AGENT_TRAILING_DASH_RE,"").slice(0,64)||"main":"main"}function narrowContentBlock(e){switch(e.type){case"text":return"string"==typeof e.text?{type:"text",text:e.text}:void 0;case"resource_link":return"string"==typeof e.uri?{type:"resource_link",uri:e.uri,...void 0!==e.name?{name:e.name}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}}:void 0;case"file":return{type:"file",...void 0!==e.text?{text:e.text}:{},...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};case"image":return{type:"image",...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};default:return}}const DEFAULT_MODE={id:"default",name:"Default",description:"Default agent mode"},USER_MESSAGE_PREFIX="User Message From BeeOS:",USER_MESSAGE_PREFIX_WITH_NEWLINE=`${USER_MESSAGE_PREFIX}\n`,SLASH_COMMAND_NAME_RE=/^[a-z0-9_-]+$/i,HISTORY_PAGE_DEFAULT_LIMIT=100,HISTORY_PAGE_MAX_LIMIT=1e3;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",...EXTERNAL_PROTOCOL_TAGS];function isBackgroundSessionKey(e){return BACKGROUND_SESSION_PATTERNS.some(t=>e.includes(t))}function isSystemDefaultSessionKey(e){const t=e.split(":");if(t.length<3||"agent"!==t[0])return!1;const s=t.slice(2).join(":");return"main"===s||s===t[1]}function isPlaceholderTitle(e){return!e||"New chat"===e||(!!/^[0-9a-f]{6,12}\s*\(20\d{2}/i.test(e)||(!!/^Sender\s*\(untrusted/i.test(e)||!!/^Conversation\s+info\s*\(untrusted/i.test(e)))}function cleanGatewayDerivedTitle(e){if(!e)return;let t=extractBeeosUserTextFromHistoryContent(e).text;return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}function cleanHistoryUserContent(e){return extractBeeosUserTextFromHistoryContent(e).text.trim()}function mergePersistedToolResultEnvelopes(e,t){if(e||t)return{toolName:e?.toolName||t?.toolName||"tool",...e?.toolCallId||t?.toolCallId?{toolCallId:e?.toolCallId??t?.toolCallId}:{},...e?.rawText??t?.rawText?{rawText:e?.rawText??t?.rawText}:{},...void 0!==e?.rawResult||void 0!==t?.rawResult?{rawResult:e?.rawResult??t?.rawResult}:{},...e?.timestamp||t?.timestamp?{timestamp:e?.timestamp??t?.timestamp}:{}}}function resolveStandaloneSlashCommandName(e,t){if(0===e.length)return;if(e.some(e=>"text"!==e.type))return;const s=t.trim();if(!s||s.includes("\n")||!s.startsWith("/"))return;const r=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return r&&!r.includes("/")&&SLASH_COMMAND_NAME_RE.test(r)?r.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`,r=stripBeeosChannelTag(e);return r?`${r}\n${s}`:s}export function decodeBeeosChannelId(e){if(!e)return;const t=BEEOS_CHANNEL_TAG_RE.exec(e);return t?.[1]}export function stripBeeosChannelTag(e){return e?e.replace(/\n?<!-- beeos:channel=.+? -->/g,"").trim():""}function deriveSessionId(e,t){const s=createHash("sha256").update(`${t}:${e}`).digest("hex").substring(0,32);return`session:${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20,32)}`}function parseAgentIdFromKey(e){const t=e.split(":");return(t.length>=3&&"agent"===t[0]?t[1]:"")||"main"}function createSessionObject(e){const t=parseAgentIdFromKey(e.gatewayKey);return{sessionId:deriveSessionId(e.gatewayKey,e.instanceId),gatewaySessionKey:e.gatewayKey,agentId:t,platformAgentId:e.resolvePlatformAgentId?.(t),createdAt:Date.now(),chatReplayDedupKeys:new Set,cwd:e.cwd,title:e.title,channelType:resolveChannelFromSessionKey(e.gatewayKey,e.thirdPartyChannels)}}class HistoryUpdateRecorder{transport;updates=[];constructor(e){this.transport=e}sendSessionUpdate(e,t,s){const r=this.transport.materializeSessionUpdate(t,s);return null!==r&&this.updates.push(r),!0}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;triggerAgentSync;sessionMutationCoordinator;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];_mappingUpdateCallbacks=[];cronIsolatedSessionKeys=new Set;cronOverrides;setAgentMapping(e){this.agentMapping=e;for(const t of this.state.sessions.values())!t.platformAgentId&&t.agentId&&(t.platformAgentId=e.get(t.agentId)?.id);for(const e of this._syncResolvers)e();this._syncResolvers=[];for(const e of this._mappingReadyCallbacks)e();this._mappingReadyCallbacks=[];for(const t of this._mappingUpdateCallbacks)t(e)}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}onAgentMappingUpdated(e){this._mappingUpdateCallbacks.push(e),this.agentMapping&&this.agentMapping.size>0&&e(this.agentMapping)}markAgentSyncDone(){for(const e of this._syncResolvers)e();this._syncResolvers=[]}waitForAgentMapping(e=15e3){return this.agentMapping?Promise.resolve():new Promise(t=>{this._syncResolvers.push(t);const s=setTimeout(()=>{const e=this._syncResolvers.indexOf(t);-1!==e&&this._syncResolvers.splice(e,1),t()},e);"object"==typeof s&&"unref"in s&&s.unref()})}handleTerminalRequest=null;handleTerminalNotification=null;onRunComplete=null;onRunFailed=null;onRunEnded=null;handleSlashCommand=null;onPromptStart=null;instanceId;constructor(e){this.state=e.state,this.transport=e.transport,this.config=e.config,this.logger=e.logger,this.writeObs=e.writeObs,this.getGateway=e.getGateway,this.instanceId=e.instanceId||"unknown",this.thirdPartyChannels=e.thirdPartyChannels??new Set,this.beeosFileResolver=e.beeosFileResolver,this.agentFetch=e.agentFetch,this.agentGatewayUrl=e.agentGatewayUrl,this.triggerAgentSync=e.triggerAgentSync,this.sessionMutationCoordinator=e.sessionMutationCoordinator,this.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.cronOverrides=createCronOverridesStore(this.logger),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const r=s?.turnId;if(!r)return;const n=this.state.sessions.get(e);n&&(recordTurnIdForRole(n,t,r),recordToolCallTurnForUpdate(n,t,r))})}resolvePlatformAgentId(e){return this.agentMapping?.get(e)?.id}getDefaultAgentId(){return this.agentMapping&&this.agentMapping.size>0?this.agentMapping.keys().next().value??"main":"main"}registerSession(e,t={}){const{ensureVerbose:s=!0}=t;this.state.sessions.has(e.sessionId);this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),s&&this.ensureVerboseFull(e),this.subscribeSessionMessages(e)}async ensureVerboseFull(e){if(e.verbosePatched)return;const t=this.getGateway();if(t?.isConnected){e.verbosePatched=!0;try{await t.sessionsPatch(e.gatewaySessionKey,{verboseLevel:"full"})}catch(t){e.verbosePatched=!1,this.logger.debug?.(`[bridge-core] ensureVerboseFull failed: ${t instanceof Error?t.message:t}`)}}}ensureSessionForKey(e){const t=this.state.findSessionByKey(e);if(t)return t;const s=createSessionObject({gatewayKey:e,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels});return this.registerSession(s),this.logger.debug?.(`[bridge-core] ensureSessionForKey: lazily created session sid=${s.sessionId} key=${e}`),s}async preloadSessionsFromGateway(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.sessionsList();if(!t||"object"!=typeof t)return;const s=t.sessions;if(!Array.isArray(s))return;let r=0;for(const e of s){const t=e,s=t.sessionKey??t.key??t.id??t.name;if(!s||"string"!=typeof s)continue;if(this.state.findSessionByKey(s))continue;const n=t.label||t.derivedTitle||t.title||void 0,i=n&&!isPlaceholderTitle(n)?n:void 0,o=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:i});this.registerSession(o,{ensureVerbose:!1}),r++}r>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${r} session(s) from gateway`)}catch(e){this.logger.debug?.("[bridge-core] preloadSessions failed:",e)}else this.logger.debug?.("[bridge-core] preloadSessions skipped: gateway not connected")}async handleRequest(e){this.writeObs({component:"bridge-core",domain:"forward",name:`rpc.request.${e.method}`,severity:"debug",payload:{id:e.id,method:e.method}});const t=e;try{switch(t.method){case"initialize":this.handleInitialize(t);break;case"ping":this.transport.sendResult(t.id,{pong:!0});break;case"session/new":await this.handleSessionNew(t);break;case"session/load":await this.handleSessionLoad(t);break;case"session/list":await this.handleSessionList(t);break;case"session/rename":this.handleSessionRename(t);break;case"session/delete":await this.handleSessionDelete(t);break;case"session/clear":await this.handleSessionClear(t);break;case"session/prompt":await this.handleSessionPrompt(t);break;case"session/set_mode":await this.handleSessionSetMode(t);break;case"session/set_model":await this.handleSessionSetModel(t);break;case"agent/create":await this.handleAgentCreate(t);break;case"agent/update":await this.handleAgentUpdate(t);break;case"agent/delete":await this.handleAgentDelete(t);break;case"agent/applyTemplate":await this.handleApplyTemplate(t);break;case"skills/list":await this.handleSkillsList(t);break;case"skills/install":await this.handleSkillsInstall(t);break;case"skills/uninstall":await this.handleSkillsUninstall(t);break;case"skills/update":await this.handleSkillsUpdate(t);break;case"models/list":await this.handleModelsList(t);break;case"session/cancel":await this.handleSessionCancel(t.id,t.params);break;case"cron/list":case"cron/status":case"cron/add":case"cron/update":case"cron/remove":case"cron/run":case"cron/runs":await this.handleCronRequest(t);break;default:"agent/list"===e.method?await this.handleAgentList(e):"_beeos/session/history"===e.method?await this.handleSessionHistoryPage(e):"mcp/list"===e.method?await this.mcp.handleList(e):"mcp/set"===e.method?await this.mcp.handleSet(e):"mcp/unset"===e.method?await this.mcp.handleUnset(e):e.method.startsWith("terminal/")&&this.handleTerminalRequest?await this.handleTerminalRequest(e):this.transport.sendAcpError(e.id,Errors.MethodNotFound,{message:`Method not found: ${e.method}`})}}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:t instanceof Error?t.message:"Internal error"})}}handleCanvasNotification=null;getCanvasState=null;onSessionLoaded=null;onSkillInstalled=null;onSessionDeleted=null;onSessionCleared=null;handleNotification(e){switch(e.method){case"session/cancel":this.observeLifecycle(this.handleSessionCancel(void 0,e.params),"session_cancel_notification");break;case"canvas/toggle":case"canvas/clear":this.handleCanvasNotification?this.handleCanvasNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Canvas notification ignored (canvas disabled): ${e.method}`);break;case"canvas/reference":{const t=e.params,s=t?.surfaceId,r=this.state.resolveSessionFromParams(t);if(!r){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}r.referencedSurfaceId=s||void 0,this.logger.debug?.(`[bridge-core] Canvas reference ${s?"set":"cleared"}: ${s??"(none)"}`);break}default:e.method.startsWith("terminal/")&&this.handleTerminalNotification?this.handleTerminalNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Unknown notification: ${e.method}`)}}handleInitialize(e){this.initialized=!0;const t=this.transport.forwardControl;!1===e.params?.forwardThinking&&(t.forwardThinking=!1),!1===e.params?.forwardToolCalls&&(t.forwardToolCalls=!1),this.transport.sendResult(e.id,{protocolVersion:ACP_PROTOCOL_VERSION,agentCapabilities:{loadSession:!0,skills:!0,multipleSessions:!0,sessionCapabilities:{"web-ssh":!!this.config.bridge.shell.enabled,canvas:!!this.config.canvasEnabled},promptCapabilities:{image:!0,audio:!1,embeddedContext:!0},mcp:{}},agentInfo:{name:"beeos-claw",title:"BeeOS Agent Bridge",version:"0.1.0"},_meta:{},authMethods:[]})}async handleSessionNew(e){const t=`agent:${"string"==typeof e.params?.agentId&&e.params.agentId||this.config.gateway.agentId||this.getDefaultAgentId()}:${randomUUID()}`,s="string"==typeof e.params?.cwd?e.params.cwd:".",r=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(r,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:r.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:r.agentId,platformAgentId:r.platformAgentId??null}}),this.fetchHistoryInBackground(t)}fetchHistoryInBackground(e){const t=this.getGateway();t?.isConnected&&loadHistory(t,e,50).catch(e=>{this.logger.debug?.(`[bridge-core] Background history fetch failed: ${e instanceof Error?e.message:e}`)})}async subscribeSessionMessages(e){if(e.messageSubscriptionActive)return;const t=this.getGateway();if(t?.isConnected)try{await t.sessionsMessagesSubscribe(e.gatewaySessionKey),e.messageSubscriptionActive=!0,this.logger.debug?.(`[bridge-core] session.message subscribed sessionKey=${e.gatewaySessionKey}`)}catch(t){this.logger.debug?.(`[bridge-core] session.message subscribe failed key=${e.gatewaySessionKey}: ${t instanceof Error?t.message:t}`)}}async resubscribeAllSessionMessages(){for(const e of this.state.sessions.values())e.messageSubscriptionActive=!1,await this.subscribeSessionMessages(e)}handleSessionMessage(e){const t=e?.sessionKey,s=e?.messageId;if(!t||!s)return;const r=this.state.sessionsByGatewayKey.get(t);if(!r)return;const n=e.message?.role;if("toolResult"===n||"tool"===n)return void this.handlePersistedToolResultMessage(r,e).catch(e=>{this.logger.warn?.(`[bridge-core] persisted toolResult handling failed sessionKey=${t} messageId=${s}: ${e instanceof Error?e.message:String(e)}`)});const i="user"===n?"user":"system"===n?"system":"assistant",o=r.lastTurnIdByRole?.[i];if(!o)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${i} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:o};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(r.sessionId,{sessionUpdate:"message_id_bind",role:i,messageId:s,...l?{timestamp:l}:{}},d),r.lastTurnIdByRole&&(r.lastTurnIdByRole[i]=void 0),"assistant"===i&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:()=>this.getCanvasState?.()??null,isSessionAlive:e=>this.state.sessions.has(e)},{session:r,turnId:o,messageId:s,runId:void 0})}async handlePersistedToolResultMessage(e,t){const s=t.messageId;if(!s)return;const r=extractPersistedToolResultEnvelope(t.message),n=this.extractPersistedToolResultFromHistory(e,s),i=mergePersistedToolResultEnvelopes(n,r);if(!i?.toolCallId)return void this.writeObs({component:"bridge-core",domain:"tool_result",name:"tool_result.skipped_no_tool_call_id",severity:"warn",payload:{sessionId:e.sessionId,messageId:s}});n&&this.writeObs({component:"bridge-core",domain:"tool_result",name:"tool_result.hydrated_from_history",severity:"debug",payload:{sessionId:e.sessionId,messageId:s,toolCallId:i.toolCallId,toolName:i.toolName}});const o=e.toolCallTurns?.get(i.toolCallId),a=o?.turnId??resolveTurnId(e);if(!a)return void this.writeObs({component:"bridge-core",domain:"tool_result",name:"tool_result.skipped_no_turn",severity:"warn",payload:{sessionId:e.sessionId,messageId:t.messageId,toolCallId:i.toolCallId,toolName:i.toolName}});const l=projectPersistedToolResultForReplay({toolCallId:i.toolCallId,toolName:i.toolName||o?.toolName||"tool",...i.rawText?{rawText:i.rawText}:{},...i.rawResult?{rawResult:i.rawResult}:{},...i.timestamp?{timestamp:i.timestamp}:{}});await emitToolResultProjectionForTurn({session:e,projection:l,toolCallId:i.toolCallId,turnId:a,config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:()=>this.getCanvasState?.()??null,sendUpdate:s=>(a&&void 0===s._turnId&&(s._turnId=a),this.transport.sendSessionUpdate(e.sessionId,s,{turnId:a,..."number"==typeof t.messageSeq?{seq:t.messageSeq}:{}}))})}extractPersistedToolResultFromHistory(e,t){if(!e.agentId)return;const s=this.localSessionHistory.readEntries({agentId:e.agentId,sessionKey:e.gatewaySessionKey,sessionId:e.sessionId});return findPersistedToolResultEnvelopeByMessageId(s,t)}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const r=s.sessions;if(!Array.isArray(r))return;for(const t of r){const s=t,r=s.sessionKey??s.key??s.id??s.name;if(r&&"string"==typeof r&&deriveSessionId(r,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,n=createSessionObject({gatewayKey:r,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(n),n}}}catch(e){this.logger.debug?.("[bridge-core] recoverSessionFromGateway failed:",e)}}async resolveOrCreateSession(e){let t=this.state.sessions.get(e);if(!t&&e.startsWith("agent:")&&(t=this.state.findSessionByKey(e)),t||e.startsWith("agent:")||(t=await this.recoverSessionFromGateway(e)),t)return{session:t,created:!1};const s=e.startsWith("agent:"),r=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${r}:${e}`,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels}),this.registerSession(t),{session:t,created:!0}}async handleSessionLoad(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const{session:s}=await this.resolveOrCreateSession(t),r=s.sessionId;this.state.lastActiveSessionId=r,s.chatReplayDedupKeys.clear(),s.sessionLoadInProgress=!0;try{const e=await this.emitHistoryTurns(s);this.emitCurrentStreamSnapshot(s,e)}finally{s.sessionLoadInProgress=!1}this.transport.sendResult(e.id,{sessionId:r}),this.onSessionLoaded?.(r,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})}async emitHistoryTurns(e){const t=new Set,s=e.sessionId;try{const r=this.getGateway(),n=e.assistantMq&&!e.assistantMq.closed,i=n?null:r,o=await loadHistory(i,e.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:e.agentId,sessionId:e.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}}),a=groupIntoTurns(o),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,g=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${o.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${g}`),l>0&&e.pendingUserTurnUpdates&&e.pendingUserTurnUpdates.length>0&&(e.pendingUserTurnUpdates=[]),a.length>0&&isPlaceholderTitle(e.title)&&"manual"!==e.titleSource){const t=a.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=cleanHistoryUserContent(e.content);return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const r=cleanHistoryUserContent(t.content).slice(0,64).replace(/\n/g," ").trim();if(r){e.title=r,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:r}),this.tryGenerateTitleFromHistory(e,s,a)}}}const p=t=>`history:${e.gatewaySessionKey}:${t}`;let m=-1;if(n)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){m=e;break}const{replayedToolCallIds:h,streamCompleteCount:u}=await this.replayHistoryTurnsToSink(e,a,this.transport,{mqActive:Boolean(n),lastAsstIdx:m,turnIdFor:(e,t)=>p(t)});for(const e of h)t.add(e);this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${u} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}async replayHistoryTurnsToSink(e,t,s,r){const n=e.sessionId,i=new Set;let o=0;for(let a=0;a<t.length;a++){const l=t[a],d=r.turnIdFor(l,a);if(r.mqActive&&a===r.lastAsstIdx){const e=classifyHistoricalAssistantTurnCompletion(l,{mqActive:r.mqActive,isSkippedForLiveMq:!0});this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] assistant — ${"live"===e.kind?e.reason:"mq active"}, deferring to emitCurrentStreamSnapshot`);continue}if("user"===l.role&&"internal_system"===l.provenance?.kind){this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] internal_system user message`);continue}if("user"===l.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(l.content)){this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] system-injected user message`);continue}if("user"===l.role&&isHeartbeatPromptText(l.content)){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewSanitizedText(l.content)}"`);continue}if("user"===l.role&&isCronPromptText(l.content)){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] cron-triggered user message sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewSanitizedText(l.content)}"`);continue}if("assistant"===l.role){const t=classifyHeartbeatFilter(l.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewSanitizedText(l.content)}"`);continue}}const c="assistant"===l.role?classifyHistoricalAssistantTurnCompletion(l,{mqActive:r.mqActive,isSkippedForLiveMq:!1}):void 0;if("error"===c?.kind||"interrupted"===c?.kind&&c.failure)s.sendSessionUpdate(n,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},...l.timestamp?{timestamp:l.timestamp}:{}},{turnId:d,...void 0!==l._seq?{seq:l._seq}:{}}),s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:c.stopReason,error:c.message,errorCode:c.errorCode},{turnId:d}),this.logger.info?.(`[bridge-core] history_runtime_failure_terminalized session_id=${n} turn_id=${d} error_code=${c.errorCode} stop_reason=${c.stopReason} source=history_replay`),o+=1;else if("system"!==l.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${a}] role=${l.role} contentLen=${l.content.length} content=${JSON.stringify(l.content.slice(0,80))} tcCount=${l.toolCalls?.length??0}`),"user"===l.role)this.replayUserTurnAsChunks(s,n,l.content,l.metadata,l.timestamp,d,l._seq);else{if(await this.replayAssistantTurnAsStream(s,n,l,d),l.toolCalls)for(const e of l.toolCalls)e.id&&i.add(e.id);const e=c??classifyHistoricalAssistantTurnCompletion(l,{mqActive:r.mqActive,isSkippedForLiveMq:!1});if("interrupted"===e.kind){const t=new Set(e.incompleteToolCallIds);for(const r of l.toolCalls??[])t.has(r.id)&&s.sendSessionUpdate(n,buildToolCallUpdatePayload({toolCallId:r.id,toolName:r.name,outputText:e.message,kind:r.kind,status:"failed",timestamp:l.timestamp}),{turnId:d});this.logger.info?.(`[bridge-core] history_orphan_turn_terminalized session_id=${n} turn_id=${d} tool_call_ids=${e.incompleteToolCallIds.join(",")} stop_reason=${e.stopReason} source=history_replay`),s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:e.stopReason,error:e.message,errorCode:e.errorCode},{turnId:d}),o+=1;continue}if("completed"!==e.kind)continue;s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:e.stopReason},{turnId:d}),o+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${a}] contentLen=${l.content.length}`),s.sendSessionUpdate(n,{sessionUpdate:"system_message_chunk",content:{type:"text",text:l.content},metadata:l.metadata,...l.timestamp?{timestamp:l.timestamp}:{}},{turnId:d,...void 0!==l._seq?{seq:l._seq}:{}})}return{replayedToolCallIds:i,streamCompleteCount:o}}async handleSessionHistoryPage(e){const t=e.params??{},s="string"==typeof t.sessionId?t.sessionId:"";if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const r="string"==typeof t.cursor&&t.cursor.length>0?t.cursor:void 0,n="number"==typeof t.limit&&Number.isFinite(t.limit)&&t.limit>0?Math.min(1e3,Math.floor(t.limit)):100,{session:i}=await this.resolveOrCreateSession(s),o=this.getGateway(),a=await loadHistoryPage(o,i.gatewaySessionKey,{...r?{cursor:r}:{},limit:n,logger:this.logger}),l=groupIntoTurns(a.messages),d=new HistoryUpdateRecorder(this.transport);await this.replayHistoryTurnsToSink(i,l,d,{mqActive:!1,lastAsstIdx:-1,turnIdFor:(e,t)=>void 0!==e._seq?`history:${i.gatewaySessionKey}:seq:${e._seq}`:`history:${i.gatewaySessionKey}:seq:none:${r??"head"}:${t}`}),this.logger.debug?.(`[bridge-core] _beeos/session/history sid=${i.sessionId} cursor=${r??"<head>"} limit=${n} turns=${l.length} updates=${d.updates.length} hasMore=${a.hasMore} nextCursor=${a.nextCursor??"<none>"}`),this.transport.sendResult(e.id,{updates:d.updates,...a.nextCursor?{nextCursor:a.nextCursor}:{},hasMore:a.hasMore})}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const r=e.assistantMq;if(r&&!r.closed){const s=r.peek(0);let n=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const r=s[e],i=r.sessionUpdate,o=r.toolCallId;if("tool_call"!==i&&"tool_call_update"!==i||!o||!t.has(o)){if("agent_message_chunk"===i){if(""===(r.content?.text??"").trim()&&e<s.length-1){const r=s[e+1];if(("tool_call"===r.sessionUpdate||"tool_call_update"===r.sessionUpdate)&&r.toolCallId&&t.has(r.toolCallId)){n=e+1;continue}}break}break}n=e+1}const i=resolveTurnId(e),o=i?{turnId:i}:void 0;for(let t=n;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],o)}}async handleSessionList(e){const t=[];for(const[,e]of this.state.sessions)isBackgroundSessionKey(e.gatewaySessionKey)||this.cronIsolatedSessionKeys.has(e.gatewaySessionKey)||t.push({sessionId:e.sessionId,title:e.title||"New chat",cwd:e.cwd,updatedAt:e.lastStreamActivityTs??e.createdAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(e.gatewaySessionKey)});const s=this.getGateway();if(s?.isConnected)try{const e=await s.sessionsList();if(e&&"object"==typeof e&&Array.isArray(e.sessions)){const s=e.sessions;for(const e of s){const s=e,r=s.sessionKey??s.key??s.id??s.name;if(!r||"string"!=typeof r)continue;if(isBackgroundSessionKey(r))continue;if(this.cronIsolatedSessionKeys.has(r))continue;const n=deriveSessionId(r,this.instanceId),i=t.find(e=>e.sessionId===r||e.sessionId===n||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===r),o=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=o&&!isPlaceholderTitle(o)?o:void 0,l=s.model,d=s.modelProvider;if(i){if(a&&isPlaceholderTitle(i.title)){i.title=a;const e=this.state.sessions.get(i.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(i.updatedAt=e),l&&(i.model=l),d&&(i.modelProvider=d)}else{const e=createSessionObject({gatewayKey:r,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s.cwd,title:a});this.registerSession(e,{ensureVerbose:!1}),t.push({sessionId:e.sessionId,title:a||"",cwd:s.cwd,updatedAt:s.updatedAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(r),model:l,modelProvider:d})}}}}catch(e){this.logger.debug?.("[bridge-core] sessions.list enrichment failed:",e)}t.sort((e,t)=>(t.updatedAt??0)-(e.updatedAt??0)||e.sessionId.localeCompare(t.sessionId)),this.transport.sendResult(e.id,{sessions:t})}handleSessionRename(e){const t=e.params?.sessionId,s=e.params?.title;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, title"});const r=s.slice(0,64),n=this.state.sessions.get(t);if(!n)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);n.title=r,n.titleSource="manual";const i=n.gatewaySessionKey,o=this.getGateway();o?.isConnected&&o.sessionsPatch(i,{label:r}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:r})}static TITLE_TEMPLATES=[{match:["今日日报","日报"],zh:"今日工作日报",en:"Daily Work Log"},{match:["周报","周报总结","本周周报"],zh:"本周工作周报",en:"Weekly Work Report"},{match:["月报","月度汇报","月度总结"],zh:"本月工作月报",en:"Monthly Work Report"},{match:["工作日志","日志"],zh:"工作日志整理",en:"Work Log Review"},{match:["晨会","今日晨会"],zh:"今日晨会纪要",en:"Daily Standup Notes"},{match:["复盘","今日复盘"],zh:"今日工作复盘",en:"Daily Retrospective"},{match:["新会话","new chat","new conversation"],zh:"新会话",en:"New Conversation"}];static TITLE_SYSTEM_PROMPT='You are a conversation title generator. Your task is to generate a concise, human-readable title summarizing the topic of the user\'s question together with the assistant\'s answer.\n\n### Rules:\n- Length: 3-6 words in English, or 6-20 characters in Chinese (up to 24 if needed to keep all key domain terms intact).\n- Language: Match the user\'s input language exactly. Mixed zh/en input → use the dominant language.\n- Extract the core topic (noun-centric) of the Q&A exchange, not the action verb.\n- Use the assistant\'s answer to disambiguate or enrich the topic when the question alone is vague (e.g. "帮我看下" + an answer about x402 → "x402 支付集成").\n- No punctuation, no quotation marks, no emojis, no markdown.\n- No prefix like "Title:" or "Here is". Output the title only.\n- Avoid generic words: "help", "question", "chat", "problem", "task".\n- If both the question and answer are too vague to summarize, return "新会话" (for zh) or "New Conversation" (for en).\n\n### Input Format:\nThe user message will contain both the question and the assistant\'s answer, delimited like this:\n[User Question]\n...question text...\n\n[Assistant Answer]\n...answer text (may be truncated with "..." in the middle)...\n\n### Output Format:\nRespond ONLY with valid JSON, no other text:\n{"title": "<your title here>"}\n\n### Examples:\nInput:\n[User Question]\n帮我写一个关于BeeOS设备接入的PRD\n\n[Assistant Answer]\n好的,这是 BeeOS 设备接入 PRD 的草稿:1. 目标 ...\nOutput: {"title": "BeeOS 设备接入 PRD"}\n\nInput:\n[User Question]\n帮我看下\n\n[Assistant Answer]\n你说的 x402 支付协议是一个基于 HTTP 402 的链上支付标准 ...\nOutput: {"title": "x402 支付协议解析"}\n\nInput:\n[User Question]\nWhat\'s the best way to integrate x402 payment protocol?\n\n[Assistant Answer]\nYou have three options: 1. Use the official SDK ...\nOutput: {"title": "x402 Payment Integration"}\n\nInput:\n[User Question]\nhi\n\n[Assistant Answer]\nHello! How can I help you today?\nOutput: {"title": "New Conversation"}';matchTitleTemplate(e){const t=e.trim().toLowerCase();for(const s of AcpGatewayBridgeCore.TITLE_TEMPLATES)if(s.match.some(e=>e.toLowerCase()===t))return/[\u4e00-\u9fff]/.test(e)?s.zh:s.en}isGuardMessage(e){const t=e.trim();return[...t].length<3||[...t].every(e=>/[\p{P}\p{S}\s]/u.test(e))}truncateQuestionForTitle(e){return e.length>2e3?e.slice(0,1500):e}truncateAnswerForTitle(e){return e.length<=1e3?e:`${e.slice(0,500)}\n...\n${e.slice(-500)}`}generateTitleFromQA(e,t,s,r){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const n=this.matchTitleTemplate(s);if(n){e.title=n,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:n})}const i=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),o=process.env.AROUTER_API_KEY||"";if(!i||!o)return void this.logger.info?.(`[bridge-core] title-gen SKIP sid=${t}: no AROUTER env vars`);const a=`[User Question]\n${this.truncateQuestionForTitle(s)}\n\n[Assistant Answer]\n${this.truncateAnswerForTitle(r)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${r.length}`),fetch(`${i}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify({model:"openai/gpt-4o-mini",max_tokens:32,temperature:.3,messages:[{role:"system",content:AcpGatewayBridgeCore.TITLE_SYSTEM_PROMPT},{role:"user",content:a}]})}).then(async s=>{if(!s.ok){const e=await s.text().catch(()=>"");return void this.logger.info?.(`[bridge-core] title-gen FAILED sid=${t} status=${s.status} body=${e.slice(0,300)}`)}const r=await s.json(),n=r.choices?.[0]?.message?.content?.trim();if(!n)return;const i=n.replace(/^```json\s*/,"").replace(/```$/,"").trim();let o;try{o=JSON.parse(i).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${i}`)}if(!o)return;if("manual"===e.titleSource)return void this.logger.debug?.("[bridge-core] title-gen discarded: user already renamed");this.logger.info?.(`[bridge-core] title-gen OK sid=${t} title="${o}"`),e.title=o,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:o}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:o})}).catch(e=>{this.logger.info?.(`[bridge-core] title-gen ERROR sid=${t}: ${e instanceof Error?e.message:e}`)})}async tryGenerateTitleFromHistory(e,t,s){if("manual"===e.titleSource||"auto"===e.titleSource)return;const r=this.getGateway();if(!r?.isConnected)return;let n=s;if(!n)try{const t=await loadHistory(r,e.gatewaySessionKey);n=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const i=n.find(e=>{if("user"!==e.role)return!1;if(!e.content?.trim())return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=cleanHistoryUserContent(e.content);return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!i)return;const o=n.indexOf(i),a=n.slice(o+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=cleanHistoryUserContent(i.content),d=a.content.trim();l&&d&&this.generateTitleFromQA(e,t,l,d)}async handleSessionDelete(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.info?.(`[bridge-core] session/delete sessionId=${t} NOT FOUND in local state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);const r=s.gatewaySessionKey,n=this.sessionMutationCoordinator.tryStartExclusive({sessionKey:r,label:"session_delete"},"acp");if(!n.ok)return void this.sendSessionBusy(s,e.id,"active_session_mutation",n.queueDepth);const i=n.token;try{if(this.hasActiveRun(s)){const t=this.sessionMutationCoordinator.gateSnapshot(r);return void this.sendSessionBusy(s,e.id,"active_session_mutation",t.queueDepth)}this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${r} gwConnected=${!!this.getGateway()?.isConnected}`);let n=!1;const i=this.getGateway();if(i?.isConnected&&r)try{await i.sessionsDelete(r),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${r}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${r}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await i.sessionsReset(r),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${r}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${r}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else r&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${r}`);this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const o=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),o.add(e));for(const[e,t]of this.state.toolCallIdMap)o.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(r),this.transport.resetSessionUpdateCount(t),n&&this.onSessionDeleted?.(r),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${r} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}finally{this.sessionMutationCoordinator.releaseBeforeRuntime(i)}}async handleSessionClear(e){this.logger.info?.(`[bridge-core] handleSessionClear called, params=${JSON.stringify(e.params)}`);const t=e.params?.sessionId;if(!t)return this.logger.warn?.("[bridge-core] session/clear missing sessionId"),void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.warn?.(`[bridge-core] session/clear sessionId=${t} not found in state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);this.logger.info?.(`[bridge-core] session/clear sessionId=${t} gatewayKey=${s.gatewaySessionKey}`);const r=this.getGateway(),n=r?.isConnected;if(!s.promptDone&&(void 0!==s.pendingPromptId||void 0!==s.currentRunId)){this.logger.info?.(`[bridge-core] session/clear: cancelling active run sid=${t} runId=${s.currentRunId}`);const i=s.writeGateToken;let o=!1;if(n&&i){const e=await this.sessionMutationCoordinator.requestCancel(i,r);o="recovery_required"===await this.handleCancellationResult(s,e)}else n&&await r.chatAbort(s.gatewaySessionKey,s.currentRunId);return void this.transport.sendError(e.id,Errors.AgentError.rpcCode,o?SESSION_RECOVERY_REQUIRED_MESSAGE:"Cancellation is still converging",o?{code:SESSION_RECOVERY_REQUIRED_CODE,stopReason:"interrupted"}:{code:"session_cancellation_pending",stopReason:"cancelled"})}if(n&&s.gatewaySessionKey)try{await r.sessionsReset(s.gatewaySessionKey),this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${s.gatewaySessionKey}`)}catch(e){this.logger.warn?.(`[bridge-core] Gateway sessions.reset FAILED for key=${s.gatewaySessionKey}: ${e instanceof Error?e.message:e}`)}else this.logger.warn?.(`[bridge-core] session/clear skipped gateway reset: connected=${n}, key=${s.gatewaySessionKey}`);s.chatReplayDedupKeys.clear(),this.logger.info?.("[bridge-core] session/clear dedupKeys cleared, calling onSessionCleared"),this.onSessionCleared?.(t,s.gatewaySessionKey),this.transport.sendResult(e.id,{cleared:!0}),this.logger.info?.("[bridge-core] session/clear DONE, sent cleared=true")}async handleSessionSetMode(e){const t=e.params?.sessionId,s=e.params?.mode,r=t?this.state.sessions.get(t):void 0,n=this.getGateway();if(r&&n?.isConnected&&s){const e={};if("verbose"===s||"detailed"===s?e.verboseLevel="full":"concise"!==s&&"default"!==s||(e.verboseLevel="default"),"reasoning"!==s&&"thinking"!==s||(e.reasoningLevel="stream"),Object.keys(e).length>0)try{await n.sessionsPatch(r.gatewaySessionKey,e)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch failed: ${e instanceof Error?e.message:e}`)}}this.transport.sendResult(e.id,{mode:s??"default"})}async handleSessionSetModel(e){const t=e.params?.sessionId,s=e.params?.model,r=t?this.state.sessions.get(t):void 0,n=this.getGateway();let i=s??null,o=null;if(r&&n?.isConnected&&s)try{const e=await n.sessionsPatch(r.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(i=t.model),t?.modelProvider&&(o=t.modelProvider)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch for model failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{model:i,modelProvider:o})}async createAgentShell(e,t,s){const r=this.getGateway();if(!r?.isConnected)throw new Error("gateway not connected");const n=`agent-${randomUUID().replace(/-/g,"").slice(0,12)}`,i=n,o=e,a=join(this.getOpenclawHome(),`workspace-${n}`),l=await r.request("agents.create",{name:i,workspace:a,...s?{model:s}:{},...t?.emoji?{emoji:t.emoji}:{},...t?.avatar?{avatar:t.avatar}:{}}),d=l?.agentId??n,c=l?.workspace??a;if(o)try{await r.request("agents.update",{agentId:d,name:o})}catch(e){this.logger.warn?.(`[bridge-core] createAgentShell: failed to set display name for "${d}": ${e instanceof Error?e.message:e}`)}return{agentId:d,workspace:c}}async handleAgentCreate(e){const t=e.params,s=(t?.name??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});const r=this.getGateway();if(r?.isConnected)try{const{agentId:n,workspace:i}=await this.createAgentShell(s,t?.identity,t?.model),o=t?.files??{};for(const[e,t]of Object.entries(o))if("IDENTITY.md"!==e){if("string"==typeof t)try{await r.request("agents.files.set",{agentId:n,name:e,content:t})}catch(t){this.logger.warn?.(`[bridge-core] agent/create: failed to write "${e}" for "${n}": ${t instanceof Error?t.message:t}`)}}else this.logger.warn?.("[bridge-core] agent/create: ignoring IDENTITY.md in files (identity is set via structured fields)");this.logger.info?.(`[bridge-core] Created agent "${n}" (name="${s}") workspace=${i}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/create: agent sync after create failed: ${e instanceof Error?e.message:e}`)}const a=this.agentMapping?.get(n)?.id??"";this.transport.sendResult(e.id,{agentId:n,platformAgentId:a,workspace:i})}catch(t){this.logger.error?.(`[bridge-core] Failed to create agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Create agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleApplyTemplate(e){const t=e.params,s=(t?.name??"").trim(),r=(t?.packageUrl??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});if(!r)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"packageUrl"});const n=this.getGateway();if(n?.isConnected)try{const n=await fetch(r);if(!n.ok)throw new TemplatePackageError(`failed to download package: HTTP ${n.status}`);const i=Buffer.from(await n.arrayBuffer());if(i.length>MAX_ZIP_BYTES)throw new TemplatePackageError(`package too large: ${i.length} bytes (max ${MAX_ZIP_BYTES})`);const o=t?.sha256?.trim().toLowerCase();if(o){if(createHash("sha256").update(i).digest("hex")!==o)throw new TemplatePackageError("package sha256 mismatch")}const a=parseAndValidateTemplatePackage(i),{agentId:l,workspace:d}=await this.createAgentShell(s,t?.identity,t?.model);let c=0;for(const[e,t]of a.workspaceFiles){const s=resolve(d,e),r=relative(d,s);if(""===r||r.startsWith("..")||path.isAbsolute(r))throw new TemplatePackageError(`refusing to write outside workspace: ${e}`);await mkdir(path.dirname(s),{recursive:!0}),await writeFile(s,t),c++}const g=a.manifest?.slug??"(no manifest)",p=a.manifest?.version??"";this.logger.info?.(`[bridge-core] agent/applyTemplate: applied "${g}${p?`@${p}`:""}" to agent "${l}" (${c} files) workspace=${d}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/applyTemplate: agent sync after apply failed: ${e instanceof Error?e.message:e}`)}const m=this.agentMapping?.get(l)?.id??"";this.transport.sendResult(e.id,{agentId:l,platformAgentId:m,workspace:d})}catch(t){if(t instanceof TemplatePackageError)return this.logger.warn?.(`[bridge-core] agent/applyTemplate: invalid package: ${t.message}`),void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:`Invalid template package: ${t.message}`});this.logger.error?.(`[bridge-core] Failed to apply template for "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Apply template failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentUpdate(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});const r=t?.name?.trim(),n=t?.model?.trim(),i=t?.identity?.emoji?.trim(),o=t?.identity?.avatar?.trim();if(!(r||n||i||o))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"No updatable fields provided (name/model/identity)"});const a=this.getGateway();if(a?.isConnected)try{const t=await a.request("agents.update",{agentId:s,...r?{name:r}:{},...n?{model:n}:{},...i?{emoji:i}:{},...o?{avatar:o}:{}}),l=t?.agentId??s;this.logger.info?.(`[bridge-core] Updated agent "${l}"${r?` name="${r}"`:""}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/update: agent sync after update failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:l})}catch(t){this.logger.error?.(`[bridge-core] Failed to update agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentDelete(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});if("main"===normalizeAgentId(s))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:'"main" cannot be deleted; delete the instance instead'});const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const n=t?.deleteFiles??!0;try{const t=await r.request("agents.delete",{agentId:s,deleteFiles:n}),i=t?.agentId??s;this.logger.info?.(`[bridge-core] Deleted agent "${i}" (deleteFiles=${n})`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/delete: agent sync after delete failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:i,..."number"==typeof t?.removedBindings?{removedBindings:t.removedBindings}:{}})}catch(t){this.logger.error?.(`[bridge-core] Failed to delete agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Delete agent failed: ${t instanceof Error?t.message:String(t)}`})}}async handleAgentList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("agents.list",{}),r=(s?.agents??[]).filter(e=>"string"==typeof e.id&&e.id.length>0).map(e=>{const t=e.identity?.avatarUrl||e.identity?.avatar;return{agentId:e.id,name:e.identity?.name||e.name||e.id,...e.identity?.emoji?{emoji:e.identity.emoji}:{},...t?{avatarUrl:t}:{}}}),n={...s?.defaultId?{defaultId:s.defaultId}:{},agents:r};this.transport.sendResult(e.id,n)}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list agents: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleModelsList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("models.list",{});this.transport.sendResult(e.id,s??{models:[]})}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list models: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleSkillsList(e){const t=e.params?.agentId,s=await this.fetchSimplifiedSkills(t);this.transport.sendResult(e.id,{skills:s})}async fetchSimplifiedSkills(e){const t=this.getGateway();if(!t?.isConnected)return[];try{const s=await t.skillsStatus(e);if(!s?.skills)return[];const r=s.skills.filter(e=>!1!==e.eligible||!0===e.disabled).sort((e,t)=>e.always&&!t.always?-1:!e.always&&t.always?1:e.name.localeCompare(t.name)).map(e=>({name:e.name,description:e.description,emoji:e.emoji,always:e.always||void 0,bundled:e.bundled||void 0,disabled:e.disabled||void 0,skillKey:e.skillKey||void 0,iconUrl:void 0})),n=await this.getSkillsDir(e);for(const e of r){const t=e.skillKey??e.name;try{const s=await readFile(join(n,t,".beeos-meta.json"),"utf8"),r=JSON.parse(s);r?.iconUrl&&(e.iconUrl=r.iconUrl)}catch{}}return r}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(e){const t=await this.fetchSimplifiedSkills(e);this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:t}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:t.length}})}getOpenclawHome(){return process.env.OPENCLAW_HOME||join(homedir(),".openclaw")}async getSkillsDir(e){const t=this.getOpenclawHome();if(!e||"main"===e)return join(t,"skills");const s=await this.resolveAgentWorkspaceDir(e);return join(s??join(t,`workspace-${e}`),"skills")}async resolveAgentWorkspaceDir(e){const t=this.getGateway();if(!t?.isConnected)return null;try{const s=await t.request("agents.list",{}),r=normalizeAgentId(e),n=s?.agents?.find(e=>normalizeAgentId(e.id)===r);return n?.workspace??null}catch(t){return this.logger.warn?.(`[bridge-core] resolveAgentWorkspaceDir("${e}") failed: ${t instanceof Error?t.message:t}`),null}}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,r=t?.files,n=t?.agentId;let i;try{i=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!r?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(await this.getSkillsDir(n),i);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(r?.length){const e=resolve(o);for(const t of r){const s=join(o,t.path);if(!resolve(s).startsWith(e+sep))throw new Error(`File path escapes skill directory: ${t.path}`);await mkdir(join(s,".."),{recursive:!0});const r=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!r.ok)throw new Error(`Failed to download ${t.path}: ${r.status}`);const n=Buffer.from(await r.arrayBuffer());await writeFile(s,n)}}if(t?.iconUrl)try{await writeFile(join(o,".beeos-meta.json"),JSON.stringify({iconUrl:t.iconUrl}),"utf8")}catch{}this.logger.info?.(`[bridge-core] Installed skill "${i}"${n?` for agent "${n}"`:""} via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList(n)}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${i}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Install failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUninstall(e){const t=e.params,s=t?.agentId;let r;try{r=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const n=resolveSkillInstallDir(await this.getSkillsDir(s),r),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),r);try{let t=!1;try{const e=await lstat(n);if(e.isSymbolicLink()){const e=await readlink(n);await rm(n,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(n,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(i);(e.isDirectory()||e.isSymbolicLink())&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${r}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${r}"${s?` for agent "${s}"`:""}`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList(s)}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${r}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Uninstall failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUpdate(e){const t=e.params,s=t?.skillKey,r=t?.enabled,n=t?.agentId;if(!s||"boolean"!=typeof r)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const i=this.getGateway();if(i?.isConnected)try{const t=await i.skillsUpdate(s,r,n);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${r}${n?` for agent "${n}"`:""}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:r}),await this.pushSkillsList(n)}catch(t){this.logger.error?.(`[bridge-core] Failed to update skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.InternalError,{message:"Gateway not connected"})}async handleSessionPrompt(e){const t=e.params?.sessionId,s=e.params?.prompt;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, prompt"});const{session:r,created:n}=await this.resolveOrCreateSession(t);n&&this.onSessionLoaded?.(r.sessionId,r.gatewaySessionKey,r.platformAgentId);const i=r.sessionId;this.state.lastActiveSessionId=i,this.onPromptStart?.();const o=this.getGateway();if(!o?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);if(this.hasActiveRun(r)){const t=this.sessionMutationCoordinator.gateSnapshot(r.gatewaySessionKey);return void this.sendSessionBusy(r,e.id,"active_run",t.queueDepth)}try{await this.ensureVerboseFull(r)}catch(t){return this.releaseSessionWriteGate(r),void this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to prepare agent context: ${t instanceof Error?t.message:String(t)}`})}let a=extractPromptBlocks(sanitizePromptPayload(s));const l=a.filter(e=>"text"===e.type&&"text"in e&&"string"==typeof e.text).map(e=>e.text).join("\n");if(r.fileArtifactDisplayMode=prefersChatFileArtifacts(l)?"chat":"auto",!r.title||isPlaceholderTitle(r.title)){const e=a.find(e=>"text"===e.type&&e.text);if(e&&"text"in e&&e.text){const t=stripTransportMetadata(e.text).trim();if(this.logger.info?.(`[bridge-core] title-gen sid=${i} existing="${r.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();r.title=e,r.titleSource="truncated",o?.isConnected&&e&&o.sessionsPatch(r.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(i,{sessionUpdate:"title_update",title:e})}}}const d=buildResolutionPlan(a);if(d.refs.length>0)try{const e=await resolveResources(d,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let c;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{c=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const g=toGatewayPrompt(a,{beeosFileResolutionsByUri:c});if("error"in g)return this.releaseSessionWriteGate(r),void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:g.error});const{message:p,attachments:m}=g,h=resolveStandaloneSlashCommandName(a,p)??resolveSlashCmd(a,p),u="string"==typeof h;if(h&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(h)}catch(e){return t.logger.warn?.(`[bridge-core] /${h} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(r,a,resolveTurnId(r)),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${h}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(r)}),this.transport.sendResult(e.id,{stopReason:"end_turn"}),void this.releaseSessionWriteGate(r)}const y="chat.send";let f=u?p:withUserMessagePrefix(p);const w=this.config.canvasEnabled&&!u&&!0===r.canvasActive&&!r.channelType;if(w){const e=r.promptsSinceCanvasInjection??0;!r.canvasPromptInjected||e>=10?(f=`${CANVAS_TERM_MAPPING}\n\n${f}`,r.canvasPromptInjected=!0,r.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):r.promptsSinceCanvasInjection=e+1}if(w){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(r.gatewaySessionKey);f=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${f}`}const t=r.referencedSurfaceId;t&&(f=`${CANVAS_UPDATE_HINT}\n[IMPORTANT: The user selected canvas surface "${t}" for editing. You MUST call render_ui with id="${t}" to UPDATE this existing surface. Do NOT create a new surface with a different id.]\n${f}`,r.referencedSurfaceId=void 0)}if(!u){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){r.activeMentions=t;f=`${buildMentionInstruction(t)}\n${f}`,this.logger.debug?.(`[bridge-core] delegation mention instruction injected for ${t.length} agent(s)`)}else r.activeMentions=void 0}if(u&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${i} command=/${h}`),this.hasActiveRun(r)){const t=this.sessionMutationCoordinator.gateSnapshot(r.gatewaySessionKey);return void this.sendSessionBusy(r,e.id,"active_run_after_prepare",t.queueDepth)}const S=this.sessionMutationCoordinator.tryStartInteractive({sessionKey:r.gatewaySessionKey,label:"session/prompt"},"acp");if(!S.ok)return void this.sendSessionBusy(r,e.id,S.reason,S.queueDepth);r.writeGateToken=S.token,this.state.resetForNewRun(r,i,this.transport),r.pendingPromptId=e.id,r.gatewayMethod=y;const I=resolveTurnId(r);this.emitPromptAsUserSessionUpdates(r,a,I),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},I?{turnId:I}:void 0);const v=extractCronJobId(p);v&&(r.cronJobId=v),this.schedulePromptTimeout(r);const E=m.length>0?m.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,_=`req_${randomUUID().replace(/-/g,"")}`,b={sessionKey:r.gatewaySessionKey,message:f,deliver:!1,idempotencyKey:randomUUID()};E?.length&&(b.attachments=E);if(!o.sendFrame({type:"req",id:_,method:y,params:b}))return this.state.clearPromptTimeout(r),r.pendingPromptId=void 0,r.assistantMq?.close(),r.assistantMq=void 0,r.pendingUserTurnUpdates=void 0,this.releaseSessionWriteGate(r),void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});r.gatewayRequestId=_,this.state.promptsByGatewayRequestId.set(_,r)}replayUserTurnAsChunks(e,t,s,r,n,i,o){const a=splitUserReplayBlocks(s),l=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const s of a){let i;if("text"===s.type){if(!s.text)continue;i={type:"text",text:s.text}}else i="resource_link"===s.type?{type:"resource_link",uri:s.uri??"",...s.fileName?{name:s.fileName}:{},...s.mimeType?{mimeType:s.mimeType}:{}}:{type:s.type,...s.mimeType?{mimeType:s.mimeType}:{},...s.fileName?{fileName:s.fileName}:{}};e.sendSessionUpdate(t,{sessionUpdate:"user_message_chunk",content:i,...r?{metadata:r}:{},...n?{timestamp:n}:{}},l)}}async replayAssistantTurnAsStream(e,t,s,r){const n=e=>{if(r||void 0!==e)return{...r?{turnId:r}:{},...void 0!==e?{seq:e}:{}}},i=new Set,o=(s,r,o)=>{if(!s?.length)return;const a=n(o);for(const n of s)n.uri&&!i.has(n.uri)&&(i.add(n.uri),e.sendSessionUpdate(t,{sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:n.uri,name:n.name??"file",...n.mimeType?{mimeType:n.mimeType}:{}},...r?{timestamp:r}:{}},a))},a=n(s._seq);if(!s.sourceMessages?.length){const r={sessionUpdate:"history_turn",role:s.role,content:s.content,reasoning:s.reasoning,toolCalls:s.toolCalls,...s.timestamp?{timestamp:s.timestamp}:{}};return s.metadata&&(r.metadata=s.metadata),e.sendSessionUpdate(t,r,a),void o(s.files,s.timestamp,s._seq)}for(const r of s.sourceMessages){const i=r.updateType??"agent_message_chunk",a=n(r._seq);switch(i){case"agent_message_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:r.content},...r.timestamp?{timestamp:r.timestamp}:{},...r.metadata?{metadata:r.metadata}:{}},a);break;case"agent_thought_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:r.content},...r.timestamp?{timestamp:r.timestamp}:{}},a);break;case"tool_call":{const n=r.toolCallId??"",i=s.toolCalls?.find(e=>e.id===n),o=r.toolName??"tool",l=r.content??"";let d={};if(l)try{d=JSON.parse(l)}catch{}e.sendSessionUpdate(t,buildToolCallPayload({toolCallId:n,toolName:o,args:d,kind:i?.kind,status:i?.status??"in_progress",timestamp:r.timestamp}),a);break}case"tool_call_update":{const n=r.toolCallId??"",i=s.toolCalls?.find(e=>e.id===n),l=r.toolName??i?.name??"tool",d=r.content??i?.output??"",c=await this.resolveReplayResourceLinks(n,l,r.files);e.sendSessionUpdate(t,buildToolCallUpdatePayload({toolCallId:n,toolName:l,outputText:d,kind:i?.kind,status:i?.status??"completed",resourceLinks:c,timestamp:r.timestamp}),a),o(c,r.timestamp,r._seq);break}}}o(s.files,s.timestamp,s._seq)}async resolveReplayResourceLinks(e,t,s){let r=s?.map(e=>({uri:e.uri,name:e.name??"file",...e.mimeType?{mimeType:e.mimeType}:{}}));if(r?.length)return r;if(r=await readEnrichmentCache(e).catch(()=>{}),r?.length)return r;if("beeos_upload_file"!==t)return;const n=await readUploadToolResult(e).catch(()=>{});if(!n)return;const i=buildToolResultSessionContentBlocks(t,n).filter(e=>"resource_link"===e.content?.type&&e.content.uri).map(e=>({uri:e.content.uri,name:e.content.name??"file",...e.content.mimeType?{mimeType:e.content.mimeType}:{}}));return i.length>0?i:void 0}emitPromptAsUserSessionUpdates(e,t,s){const r=(new Date).toISOString(),n=s?{turnId:s}:void 0;for(const i of t){const t=this.toUserSessionUpdateContent(i);if(!t)continue;const o={sessionUpdate:"user_message_chunk",content:t,timestamp:r};s&&void 0===o._turnId&&(o._turnId=s),this.transport.sendSessionUpdate(e.sessionId,o,n),e.pendingUserTurnUpdates?.push(o)}}toUserSessionUpdateContent(e){if("text"===e.type){if(!e.text)return;return{type:"text",text:e.text}}if("image"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.name;if(!e.data&&!e.uri)return;return{type:"image",...e.data?{data:e.data}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource_link"===e.type){if(!e.uri)return;return{type:"resource_link",uri:e.uri,...e.title?{title:e.title}:{},...e.name?{name:e.name}:{},...e.mimeType??e.mime_type?{mimeType:e.mimeType??e.mime_type}:{}}}if("file"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.filename??e.name;if(!e.data&&!e.text&&!e.uri)return;return{type:"file",...e.data?{data:e.data}:{},...e.text?{text:e.text}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource"===e.type){const t=e.resource??{},s=t.uri??e.uri,r=t.mimeType??t.mime_type??e.mimeType??e.mime_type,n=t.text??e.text,i=t.data??e.data,o=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!n&&!i)return;return{type:"resource",resource:{...s?{uri:s}:{},...r?{mimeType:r}:{},...n?{text:n}:{},...i?{data:i}:{},...o?{fileName:o}:{}}}}}async handleSessionCancel(e,t){const s=t&&"object"==typeof t&&"sessionId"in t?t.sessionId:void 0;if(!s)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.MissingParam,{param:"sessionId"}));const r=this.state.sessions.get(s);if(!r)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const n=this.getGateway(),i=r.writeGateToken;if(!n?.isConnected)return i&&(this.sessionMutationCoordinator.requireRecovery(i,"gateway_disconnected","disconnect"),await this.endRun(r,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE})),void(void 0!==e&&this.transport.sendAcpError(e,Errors.GatewayNotConnected));const o=i?await this.sessionMutationCoordinator.requestCancel(i,n):await this.sessionMutationCoordinator.requestCancelForSession(r.gatewaySessionKey,n);if(!o.ok&&"reason"in o&&"no_active_mutation"===o.reason)return void(void 0!==e&&this.transport.sendResult(e,{}));if(!o.ok&&"reason"in o&&"not_cancellable"===o.reason){if(void 0!==e){const t=this.sessionMutationCoordinator.gateSnapshot(r.gatewaySessionKey);this.sendSessionBusy(r,e,"exclusive_session_mutation",t.queueDepth)}return}const a=await this.handleCancellationResult(r,o);void 0!==e&&("recovery_required"===a?this.transport.sendError(e,Errors.AgentError.rpcCode,SESSION_RECOVERY_REQUIRED_MESSAGE,{code:SESSION_RECOVERY_REQUIRED_CODE,stopReason:"interrupted"}):"terminal_already_observed"===a?this.transport.sendError(e,Errors.AgentError.rpcCode,"The run is already committing its terminal state",{code:"terminal_committing",stopReason:"interrupted"}):this.transport.sendResult(e,{}))}hasActiveRun(e){return!e.promptDone&&(void 0!==e.pendingPromptId||void 0!==e.currentRunId||void 0!==e.gatewayRequestId)}observeLifecycle(e,t,s){observeLifecyclePromise(e,this.logger,{component:"bridge-core",action:t,...s?{sessionId:s.sessionId,gatewaySessionKey:s.gatewaySessionKey}:{}})}async handleCancellationResult(e,t){return t.ok?(this.observeLifecycle(t.terminalObservation.then(t=>{if(!t.ok)return this.publishCancellationRecovery(e)}),"cancel_terminal_observation",e),"acknowledged"):"recovery"in t?(await this.publishCancellationRecovery(e),"recovery_required"):"terminal_already_observed"}async publishCancellationRecovery(e){await this.endRun(e,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE})}releaseSessionWriteGate(e){if(!e.writeGateToken)return;const t=e.writeGateToken;e.writeGateToken=void 0,this.sessionMutationCoordinator.releaseBeforeRuntime(t)}logRuntimeFailure(e,t){this.logger.warn?.(`[bridge-core] runtime_failure_classified session_id=${e.sessionId} gateway_session_key=${e.gatewaySessionKey} run_id=${e.currentRunId??""} gateway_request_id=${e.gatewayRequestId??""} error_code=${t.errorCode} stop_reason=${t.stopReason} source=${t.source} runtime=${t.runtimeKind}`)}sendSessionBusy(e,t,s,r){this.logger.warn?.(`[bridge-core] session_write_gate_busy session_id=${e.sessionId} gateway_session_key=${e.gatewaySessionKey} reason=${s} queue_depth=${r}`),this.transport.sendError(t,Errors.AgentError.rpcCode,"Agent is busy",{code:"session_busy",reason:s,queueDepth:r}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"interrupted",error:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_busy"},{turnId:`req:${String(t)}`})}async endRun(e,t){if(e.promptDone||e.mutationTerminalCommitting)return this.logger.debug?.(`[bridge-core] endRun skipped (promptDone=true) sessionId=${e.sessionId} outcome=${t.kind}`),!1;if(void 0===e.pendingPromptId&&void 0===e.currentRunId&&void 0===e.gatewayRequestId)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;e.mutationTerminalCommitting=!0,this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const s=e.writeGateToken;let r,n,i,o,a="timeout"===e.mutationTerminalOverride?{kind:"interrupted",stopReason:"timeout",message:"The agent did not finish before the session timeout.",errorCode:"gateway_timeout"}:t;if(s){const e="complete"===a.kind?"cancelled"===a.stopReason?"cancelled":"completed":"interrupted"===a.kind&&"timeout"===a.stopReason?"timeout":"error",t=await this.sessionMutationCoordinator.converge(s,e);t.ok?r=t.permit:a={kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE}}try{const t=resolveTurnId(e),s=t?{turnId:t}:void 0;e.promptDone=!0;const r=e.currentRunId;if("complete"===a.kind){if(r&&this.onRunComplete){const t=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(r,t,a.stopReason)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:a.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:a.stopReason},s),this.tryGenerateTitleFromHistory(e,e.sessionId)}else"interrupted"===a.kind?(r&&this.onRunFailed&&this.onRunFailed(r,a.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,Errors.AgentError.rpcCode,a.message,{code:a.errorCode??"interrupted",stopReason:a.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:a.stopReason,error:a.message,...a.errorCode?{errorCode:a.errorCode}:{}},s)):(r&&this.onRunFailed&&this.onRunFailed(r,a.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,a.code,a.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:a.message},s))}catch(e){n=e}try{this.cleanupPrompt(e)}catch(e){i=e}finally{e.writeGateToken=void 0,r&&this.sessionMutationCoordinator.release(r)}try{this.onRunEnded?.(e,a)}catch(e){o=e}if(n)throw n;if(i)throw i;if(o)throw o;return!0}schedulePromptTimeout(e){let t;if(this.state.clearPromptTimeout(e),e.cronJobId){const s=this.cronOverrides.get(e.cronJobId)??this.config.cronMaxDurationDefaultMs;t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,s))}else t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{if(e.mutationTerminalCommitting)return;if(void 0===e.pendingPromptId&&void 0===e.currentRunId)return;this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),e.mutationTerminalOverride="timeout";const s=this.getGateway(),r=e.writeGateToken;s?.isConnected&&r?this.observeLifecycle(this.sessionMutationCoordinator.requestCancel(r,s).then(t=>this.handleCancellationResult(e,t)),"prompt_timeout_cancel",e):s?.isConnected?this.observeLifecycle(s.chatAbort(e.gatewaySessionKey,e.currentRunId).then(()=>this.interruptPrompt(e,classifyAgentRuntimeFailure({raw:`Prompt timed out after ${t}ms`,source:"live_gateway",runtimeKind:"openclaw"}))),"prompt_timeout_unowned_cancel",e):r&&(this.sessionMutationCoordinator.requireRecovery(r,"gateway_disconnected","disconnect"),this.observeLifecycle(this.endRun(e,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE}),"prompt_timeout_disconnected",e))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}adoptBackgroundMutation(e,t){const s=this.sessionMutationCoordinator.attachOrStartExternal({sessionKey:e.gatewaySessionKey,label:"background_run"},"acp",t);return s.ok?("owner"===s.ownership&&(e.writeGateToken=s.token),s):(this.logger.error?.(`[bridge-core] background mutation rejected sessionKey=${e.gatewaySessionKey} runId=${t} reason=${s.reason}`),s)}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.delete(t),this.state.runIdToCronJobId.delete(t),this.state.markRunCompleted(t);const e=`tc_${t}_`;for(const[t,s]of this.state.toolCallIdMap)s.startsWith(e)&&this.state.toolCallIdMap.delete(t)}e.gatewayRequestId&&this.state.promptsByGatewayRequestId.delete(e.gatewayRequestId),e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,e.mutationTerminalOverride=void 0,e.mutationTerminalCommitting=void 0,e.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}async completePrompt(e,t){await this.endRun(e,{kind:"complete",stopReason:t})}async interruptPrompt(e,t){this.logRuntimeFailure(e,t),await this.endRun(e,{kind:"interrupted",stopReason:t.stopReason,message:t.publicMessage,errorCode:t.errorCode})}async failPrompt(e,t,s){await this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())e.writeGateToken?(this.sessionMutationCoordinator.requireRecovery(e.writeGateToken,"gateway_disconnected","disconnect"),this.observeLifecycle(this.endRun(e,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE}),"gateway_disconnect_recovery",e)):this.observeLifecycle(this.interruptPrompt(e,classifyAgentRuntimeFailure({raw:Errors.GatewayDisconnected.message,source:"live_gateway",runtimeKind:"openclaw"})),"gateway_disconnect_interrupt",e)}handleGatewayResponse(e){const t=e.id;if(!t)return;const s=this.state.promptsByGatewayRequestId.get(t);if(!s||s.promptDone)return;if(!e.ok){const r=e.error,n=r?.message??"gateway returned error";this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${n}`);const i=classifyAgentRuntimeFailure({raw:n,source:"live_gateway",runtimeKind:"openclaw"});return void("interrupted"===i.kind?this.observeLifecycle(this.interruptPrompt(s,i),"gateway_response_interrupt",s):(this.logRuntimeFailure(s,i),this.observeLifecycle(this.failPrompt(s,Errors.SendFailed.rpcCode,i.publicMessage),"gateway_response_failure",s)))}const r=e.payload,n=r?.runId;n?(s.currentRunId=n,this.sessionMutationCoordinator.bindRun(s.writeGateToken,n),s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(n,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${n} gatewayRequestId=${t} sessionId=${s.sessionId}`)):this.logger.debug?.(`[bridge-core] handleGatewayResponse: no runId in payload gatewayRequestId=${t} sessionId=${s.sessionId}`)}maybeCompletePromptFromChatState(e,t){if(e.promptDone||"chat.send"!==e.gatewayMethod)return;const s="string"==typeof t.state?t.state.toLowerCase():void 0;if(s)if("final"===s){const s="string"==typeof t.stopReason?t.stopReason:void 0;this.observeLifecycle(this.completePrompt(e,s||"end_turn"),"chat_state_final",e)}else if("aborted"===s||"cancelled"===s||"cancel"===s)this.observeLifecycle(this.completePrompt(e,"cancelled"),"chat_state_cancelled",e);else if("error"===s){const s=("string"==typeof t.summary?t.summary:void 0)??("string"==typeof t.message?t.message:void 0)??"gateway chat error",r=classifyAgentRuntimeFailure({raw:s,source:"chat_state",runtimeKind:"openclaw"});"interrupted"===r.kind?this.observeLifecycle(this.interruptPrompt(e,r),"chat_state_interrupt",e):(this.logRuntimeFailure(e,r),this.observeLifecycle(this.failPrompt(e,Errors.AgentError.rpcCode,r.publicMessage),"chat_state_failure",e))}}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),r={...e.params??{}},n="cron.update"===s;!n||r.patch&&"object"==typeof r.patch||(r.patch={});const i=n?r.patch:r,o=i._beeos_meta;let a;if(delete i._beeos_meta,"cron.add"!==s&&"cron.update"!==s||!0!==i._beeos_internal)void 0!==i._beeos_internal&&delete i._beeos_internal;else if(delete i._beeos_internal,i.delivery={mode:"none"},"cron.add"===s){const e=await this.allocateCronChannel(i.name??"cron");e&&(i.description=encodeBeeosChannelTag(i.description,e))}else{const e=r.id,t="description"in i,s=i.description;let n,o=decodeBeeosChannelId(s);!o&&e&&(o=this.cronChannelCache.get(e)),!o&&e&&(n=await this.lookupCronJobDescription(e),o=decodeBeeosChannelId(n));let l=!1;o||(o=await this.allocateCronChannel(i.name??"cron"),l=!0),o&&(t?i.description=encodeBeeosChannelTag(s,o):l&&(i.description=encodeBeeosChannelTag(n,o)),a=o)}try{const n=await t.request(s,r),l="cron.list"===s||"cron.status"===s?this.enrichCronResponseWithOverrides(n):n;if(this.transport.sendResult(e.id,l??{}),("cron.add"===s||"cron.update"===s)&&n&&"object"==typeof n){const e=n.id;if(e&&void 0!==o?.maxDurationMs){const t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,o.maxDurationMs));this.cronOverrides.set(e,t)}}if("cron.remove"===s){const t=e.params?.id;t&&(this.cronOverrides.delete(t),this.cronChannelCache.delete(t))}if(("cron.add"===s||"cron.update"===s)&&n&&"object"==typeof n){const e=n.id,t=decodeBeeosChannelId(i.description)??a;e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const r=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${r}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:r})}}enrichCronResponseWithOverrides(e){if(!e||"object"!=typeof e)return e;const t=e,s=e=>{if(!e||"object"!=typeof e)return e;const t=e,s=t.id;if("string"!=typeof s)return e;const r=this.cronOverrides.get(s);return"number"!=typeof r?e:{...t,_beeos_meta:{maxDurationMs:r}}};if(Array.isArray(t.jobs))return{...t,jobs:t.jobs.map(s)};if(t.jobs&&"object"==typeof t.jobs){const e=t.jobs,r={};for(const[t,n]of Object.entries(e))r[t]=s(n);return{...t,jobs:r}}return e}async allocateCronChannel(e){if(this.agentFetch&&this.agentGatewayUrl)try{const t=`${this.agentGatewayUrl}/api/v1/channels`,s=await this.agentFetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({participants:[this.instanceId,"service:cron"],metadata:{source:"cron",job_name:e}})});if(!s.ok)return void this.logger.warn?.(`[bridge-core] channel alloc failed: ${s.status}`);return(await s.json()).id}catch(e){return void this.logger.warn?.(`[bridge-core] channel alloc error: ${e instanceof Error?e.message:e}`)}else this.logger.debug?.("[bridge-core] agentFetch/agentGatewayUrl unavailable, skip channel alloc")}async lookupCronJobDescription(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("cron.list",{}),r=s?.jobs;if(!Array.isArray(r))return;for(const t of r){if(!t||"object"!=typeof t)continue;const s=t;if(s.id===e)return"string"==typeof s.description?s.description:void 0}}catch(e){this.logger.debug?.(`[bridge-core] cron.list description lookup failed: ${e instanceof Error?e.message:e}`)}}setCronChannelCache(e,t){if(this.cronChannelCache.size>=AcpGatewayBridgeCore.CRON_CHANNEL_CACHE_MAX){const e=this.cronChannelCache.keys().next().value;void 0!==e&&this.cronChannelCache.delete(e)}this.cronChannelCache.set(e,t)}async refreshCronIsolatedSessionKeys(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.request("cron.list",{}),s=t?.jobs;if(!Array.isArray(s))return;const r=new Set,n=[];for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("string"==typeof t.id&&n.push(t.id),"isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&r.add(s)}this.cronIsolatedSessionKeys=r,this.cronOverrides.reconcile(n)}catch(e){this.logger.debug?.(`[bridge-core] refreshCronIsolatedSessionKeys failed: ${e instanceof Error?e.message:e}`)}}}async function fetchWithRetry(e,t={}){const s=t.maxRetries??3;let r;for(let n=0;n<=s;n++){const i=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(i.ok)return Buffer.from(await i.arrayBuffer());if(429===i.status||i.status>=500){const o=Number(i.headers.get("retry-after"))||0,a=1e3*Math.max(o,2**n);if(r=new Error(`HTTP ${i.status} from ${e}`),n<s){t.logger?.info?.(`[bridge-core] Download ${i.status}, retry ${n+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${i.status}`)}throw r??new Error("Download failed after retries")}const SKILL_ROOT_MARKERS=["SKILL.md","skill.md","SKILL.MD"];async function resolveSkillRoot(e){for(const t of SKILL_ROOT_MARKERS)try{return await stat(join(e,t)),e}catch{}const t=(await readdir(e,{withFileTypes:!0})).filter(e=>e.isDirectory()&&!e.name.startsWith(".")&&"__MACOSX"!==e.name);if(1===t.length){const s=join(e,t[0].name);for(const e of SKILL_ROOT_MARKERS)try{return await stat(join(s,e)),s}catch{}return s}return e}async function extractZipToDir(e,t){const s=new AdmZip(e),r=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(r,{recursive:!0}),s.extractAllTo(r,!0);const e=await resolveSkillRoot(r),n=await readdir(e,{withFileTypes:!0});let i=0;for(const s of n){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const r=join(e,s.name),n=join(t,s.name);await rename(r,n).catch(async()=>{await cp(r,n,{recursive:!0})}),i++}if(0===i)throw new Error("Zip archive contained no extractable files")}finally{await rm(r,{recursive:!0,force:!0}).catch(()=>{})}}const VALID_SLUG_PATTERN=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,NON_ASCII_PATTERN=/[^\x00-\x7F]/;function validateSkillSlug(e){const t=e.trim();if(!t||t.includes("/")||t.includes("\\")||t.includes(".."))throw new Error(`Invalid skill slug: ${e}`);if(NON_ASCII_PATTERN.test(t)||!VALID_SLUG_PATTERN.test(t))throw new Error(`Invalid skill slug: ${e}`);return t}function safeDirName(e){const t=e.trim();return t?t.replaceAll("/","__").replaceAll("\\","__"):t}function resolveSkillInstallDir(e,t){const s=safeDirName(t),r=join(e,s),n=resolve(e),i=resolve(r),o=relative(n,i);if(!o||".."===o||o.startsWith(`..${sep}`)||path.isAbsolute(o))throw new Error("Invalid skill target path");return r}
|
|
1
|
+
import{randomUUID,createHash}from"node:crypto";import{mkdir,writeFile,readFile,rm,lstat,readlink,stat,readdir,rename,cp}from"node:fs/promises";import path from"node:path";import{join,resolve,relative,sep}from"node:path";import{homedir}from"node:os";import AdmZip from"adm-zip";import{sanitizePromptPayload}from"../message-sanitization/prompt-payload-sanitizer.js";import{previewSanitizedText,stripTransportMetadata}from"../message-sanitization/transport-metadata-sanitizer.js";import{isHeartbeatPromptText,isCronPromptText,extractCronJobId,classifyHeartbeatFilter}from"../message-sanitization/message-classifier.js";import{createCronOverridesStore}from"../cron-overrides.js";import{CRON_DURATION_FLOOR_MS}from"../config.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../delegation/mention-resolver.js";import{EXTERNAL_PROTOCOL_TAGS}from"./external-protocol-tags.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{extractBeeosUserTextFromHistoryContent}from"./beeos-user-envelope.js";import{loadHistory,loadHistoryPage,groupIntoTurns,splitUserReplayBlocks}from"./history-replay.js";import{classifyHistoricalAssistantTurnCompletion}from"./history-turn-completion-policy.js";import{LocalSessionHistory}from"./local-session-history.js";import{reconcileMissingArtifacts}from"./missing-artifacts-reconciler.js";import{buildResolutionPlan,resolveResources,applyResolutions}from"./prompt-resource-resolver.js";import{readEnrichmentCache,readUploadToolResult}from"../upload/result-cache.js";import{buildToolResultSessionContentBlocks}from"./tool-result-blocks.js";import{prefersChatFileArtifacts}from"./file-artifact-presenter.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"../runtime/agent-runtime-failure-classifier.js";import{SESSION_RECOVERY_REQUIRED_CODE,SESSION_RECOVERY_REQUIRED_MESSAGE}from"../runtime/session-mutation-coordinator.js";import{observeLifecyclePromise}from"../runtime/lifecycle-promise-observer.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole,recordToolCallTurnForUpdate}from"./gateway-events.js";import{emitToolResultProjectionForTurn}from"./tool-result-emitter.js";import{extractPersistedToolResultEnvelope,findPersistedToolResultEnvelopeByMessageId}from"./tool-result-transcript.js";import{projectPersistedToolResultForReplay}from"./tool-result-projection.js";import{McpService}from"./mcp-service.js";import{DEFAULT_PROMPT_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types/acp";import{parseAndValidateTemplatePackage,TemplatePackageError,MAX_ZIP_BYTES}from"./agent-template-package.js";const AGENT_VALID_ID_RE=/^[a-z0-9][a-z0-9_-]{0,63}$/i,AGENT_INVALID_CHARS_RE=/[^a-z0-9_-]+/g,AGENT_LEADING_DASH_RE=/^-+/,AGENT_TRAILING_DASH_RE=/-+$/,DEFAULT_AGENT_ID="main";export function normalizeAgentId(e){const t=(e??"").trim();return t?AGENT_VALID_ID_RE.test(t)?t.toLowerCase():t.toLowerCase().replace(AGENT_INVALID_CHARS_RE,"-").replace(AGENT_LEADING_DASH_RE,"").replace(AGENT_TRAILING_DASH_RE,"").slice(0,64)||"main":"main"}function narrowContentBlock(e){switch(e.type){case"text":return"string"==typeof e.text?{type:"text",text:e.text}:void 0;case"resource_link":return"string"==typeof e.uri?{type:"resource_link",uri:e.uri,...void 0!==e.name?{name:e.name}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}}:void 0;case"file":return{type:"file",...void 0!==e.text?{text:e.text}:{},...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};case"image":return{type:"image",...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};default:return}}const DEFAULT_MODE={id:"default",name:"Default",description:"Default agent mode"},USER_MESSAGE_PREFIX="User Message From BeeOS:",USER_MESSAGE_PREFIX_WITH_NEWLINE=`${USER_MESSAGE_PREFIX}\n`,SLASH_COMMAND_NAME_RE=/^[a-z0-9_-]+$/i,HISTORY_PAGE_DEFAULT_LIMIT=100,HISTORY_PAGE_MAX_LIMIT=1e3;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",...EXTERNAL_PROTOCOL_TAGS];function isBackgroundSessionKey(e){return BACKGROUND_SESSION_PATTERNS.some(t=>e.includes(t))}function isSystemDefaultSessionKey(e){const t=e.split(":");if(t.length<3||"agent"!==t[0])return!1;const s=t.slice(2).join(":");return"main"===s||s===t[1]}function isPlaceholderTitle(e){return!e||"New chat"===e||(!!/^[0-9a-f]{6,12}\s*\(20\d{2}/i.test(e)||(!!/^Sender\s*\(untrusted/i.test(e)||!!/^Conversation\s+info\s*\(untrusted/i.test(e)))}function cleanGatewayDerivedTitle(e){if(!e)return;let t=extractBeeosUserTextFromHistoryContent(e).text;return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}function cleanHistoryUserContent(e){return extractBeeosUserTextFromHistoryContent(e).text.trim()}function mergePersistedToolResultEnvelopes(e,t){if(e||t)return{toolName:e?.toolName||t?.toolName||"tool",...e?.toolCallId||t?.toolCallId?{toolCallId:e?.toolCallId??t?.toolCallId}:{},...e?.rawText??t?.rawText?{rawText:e?.rawText??t?.rawText}:{},...void 0!==e?.rawResult||void 0!==t?.rawResult?{rawResult:e?.rawResult??t?.rawResult}:{},...e?.timestamp||t?.timestamp?{timestamp:e?.timestamp??t?.timestamp}:{}}}function resolveStandaloneSlashCommandName(e,t){if(0===e.length)return;if(e.some(e=>"text"!==e.type))return;const s=t.trim();if(!s||s.includes("\n")||!s.startsWith("/"))return;const r=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return r&&!r.includes("/")&&SLASH_COMMAND_NAME_RE.test(r)?r.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`,r=stripBeeosChannelTag(e);return r?`${r}\n${s}`:s}export function decodeBeeosChannelId(e){if(!e)return;const t=BEEOS_CHANNEL_TAG_RE.exec(e);return t?.[1]}export function stripBeeosChannelTag(e){return e?e.replace(/\n?<!-- beeos:channel=.+? -->/g,"").trim():""}function deriveSessionId(e,t){const s=createHash("sha256").update(`${t}:${e}`).digest("hex").substring(0,32);return`session:${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20,32)}`}function parseAgentIdFromKey(e){const t=e.split(":");return(t.length>=3&&"agent"===t[0]?t[1]:"")||"main"}function createSessionObject(e){const t=parseAgentIdFromKey(e.gatewayKey);return{sessionId:deriveSessionId(e.gatewayKey,e.instanceId),gatewaySessionKey:e.gatewayKey,agentId:t,platformAgentId:e.resolvePlatformAgentId?.(t),createdAt:Date.now(),chatReplayDedupKeys:new Set,cwd:e.cwd,title:e.title,channelType:resolveChannelFromSessionKey(e.gatewayKey,e.thirdPartyChannels)}}class HistoryUpdateRecorder{transport;updates=[];constructor(e){this.transport=e}sendSessionUpdate(e,t,s){const r=this.transport.materializeSessionUpdate(t,s);return null!==r&&this.updates.push(r),!0}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;triggerAgentSync;sessionMutationCoordinator;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];_mappingUpdateCallbacks=[];cronIsolatedSessionKeys=new Set;cronOverrides;setAgentMapping(e){this.agentMapping=e;for(const t of this.state.sessions.values())!t.platformAgentId&&t.agentId&&(t.platformAgentId=e.get(t.agentId)?.id);for(const e of this._syncResolvers)e();this._syncResolvers=[];for(const e of this._mappingReadyCallbacks)e();this._mappingReadyCallbacks=[];for(const t of this._mappingUpdateCallbacks)t(e)}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}onAgentMappingUpdated(e){this._mappingUpdateCallbacks.push(e),this.agentMapping&&this.agentMapping.size>0&&e(this.agentMapping)}markAgentSyncDone(){for(const e of this._syncResolvers)e();this._syncResolvers=[]}waitForAgentMapping(e=15e3){return this.agentMapping?Promise.resolve():new Promise(t=>{this._syncResolvers.push(t);const s=setTimeout(()=>{const e=this._syncResolvers.indexOf(t);-1!==e&&this._syncResolvers.splice(e,1),t()},e);"object"==typeof s&&"unref"in s&&s.unref()})}handleTerminalRequest=null;handleTerminalNotification=null;onRunComplete=null;onRunFailed=null;onRunEnded=null;handleSlashCommand=null;onPromptStart=null;instanceId;constructor(e){this.state=e.state,this.transport=e.transport,this.config=e.config,this.logger=e.logger,this.writeObs=e.writeObs,this.getGateway=e.getGateway,this.instanceId=e.instanceId||"unknown",this.thirdPartyChannels=e.thirdPartyChannels??new Set,this.beeosFileResolver=e.beeosFileResolver,this.agentFetch=e.agentFetch,this.agentGatewayUrl=e.agentGatewayUrl,this.triggerAgentSync=e.triggerAgentSync,this.sessionMutationCoordinator=e.sessionMutationCoordinator,this.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.cronOverrides=createCronOverridesStore(this.logger),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const r=s?.turnId;if(!r)return;const n=this.state.sessions.get(e);n&&(recordTurnIdForRole(n,t,r),recordToolCallTurnForUpdate(n,t,r))})}resolvePlatformAgentId(e){return this.agentMapping?.get(e)?.id}getDefaultAgentId(){return this.agentMapping&&this.agentMapping.size>0?this.agentMapping.keys().next().value??"main":"main"}registerSession(e,t={}){const{ensureVerbose:s=!0}=t;this.state.sessions.has(e.sessionId);this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),s&&this.ensureVerboseFull(e),this.subscribeSessionMessages(e)}async ensureVerboseFull(e){if(e.verbosePatched)return;const t=this.getGateway();if(t?.isConnected){e.verbosePatched=!0;try{await t.sessionsPatch(e.gatewaySessionKey,{verboseLevel:"full"})}catch(t){e.verbosePatched=!1,this.logger.debug?.(`[bridge-core] ensureVerboseFull failed: ${t instanceof Error?t.message:t}`)}}}ensureSessionForKey(e){const t=this.state.findSessionByKey(e);if(t)return t;const s=createSessionObject({gatewayKey:e,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels});return this.registerSession(s),this.logger.debug?.(`[bridge-core] ensureSessionForKey: lazily created session sid=${s.sessionId} key=${e}`),s}async preloadSessionsFromGateway(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.sessionsList();if(!t||"object"!=typeof t)return;const s=t.sessions;if(!Array.isArray(s))return;let r=0;for(const e of s){const t=e,s=t.sessionKey??t.key??t.id??t.name;if(!s||"string"!=typeof s)continue;if(this.state.findSessionByKey(s))continue;const n=t.label||t.derivedTitle||t.title||void 0,i=n&&!isPlaceholderTitle(n)?n:void 0,o=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:i});this.registerSession(o,{ensureVerbose:!1}),r++}r>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${r} session(s) from gateway`)}catch(e){this.logger.debug?.("[bridge-core] preloadSessions failed:",e)}else this.logger.debug?.("[bridge-core] preloadSessions skipped: gateway not connected")}async handleRequest(e){this.writeObs({component:"bridge-core",domain:"forward",name:`rpc.request.${e.method}`,severity:"debug",payload:{id:e.id,method:e.method}});const t=e;try{switch(t.method){case"initialize":this.handleInitialize(t);break;case"ping":this.transport.sendResult(t.id,{pong:!0});break;case"session/new":await this.handleSessionNew(t);break;case"session/load":await this.handleSessionLoad(t);break;case"session/list":await this.handleSessionList(t);break;case"session/rename":this.handleSessionRename(t);break;case"session/delete":await this.handleSessionDelete(t);break;case"session/clear":await this.handleSessionClear(t);break;case"session/prompt":await this.handleSessionPrompt(t);break;case"session/set_mode":await this.handleSessionSetMode(t);break;case"session/set_model":await this.handleSessionSetModel(t);break;case"agent/create":await this.handleAgentCreate(t);break;case"agent/update":await this.handleAgentUpdate(t);break;case"agent/delete":await this.handleAgentDelete(t);break;case"agent/applyTemplate":await this.handleApplyTemplate(t);break;case"skills/list":await this.handleSkillsList(t);break;case"skills/install":await this.handleSkillsInstall(t);break;case"skills/uninstall":await this.handleSkillsUninstall(t);break;case"skills/update":await this.handleSkillsUpdate(t);break;case"models/list":await this.handleModelsList(t);break;case"session/cancel":await this.handleSessionCancel(t.id,t.params);break;case"cron/list":case"cron/status":case"cron/add":case"cron/update":case"cron/remove":case"cron/run":case"cron/runs":await this.handleCronRequest(t);break;default:"agent/list"===e.method?await this.handleAgentList(e):"_beeos/session/history"===e.method?await this.handleSessionHistoryPage(e):"mcp/list"===e.method?await this.mcp.handleList(e):"mcp/set"===e.method?await this.mcp.handleSet(e):"mcp/unset"===e.method?await this.mcp.handleUnset(e):e.method.startsWith("terminal/")&&this.handleTerminalRequest?await this.handleTerminalRequest(e):this.transport.sendAcpError(e.id,Errors.MethodNotFound,{message:`Method not found: ${e.method}`})}}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:t instanceof Error?t.message:"Internal error"})}}handleCanvasNotification=null;getCanvasState=null;onSessionLoaded=null;onSkillInstalled=null;onSessionDeleted=null;onSessionCleared=null;handleNotification(e){switch(e.method){case"session/cancel":this.observeLifecycle(this.handleSessionCancel(void 0,e.params),"session_cancel_notification");break;case"canvas/toggle":case"canvas/clear":this.handleCanvasNotification?this.handleCanvasNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Canvas notification ignored (canvas disabled): ${e.method}`);break;case"canvas/reference":{const t=e.params,s=t?.surfaceId,r=this.state.resolveSessionFromParams(t);if(!r){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}r.referencedSurfaceId=s||void 0,this.logger.debug?.(`[bridge-core] Canvas reference ${s?"set":"cleared"}: ${s??"(none)"}`);break}default:e.method.startsWith("terminal/")&&this.handleTerminalNotification?this.handleTerminalNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Unknown notification: ${e.method}`)}}handleInitialize(e){this.initialized=!0;const t=this.transport.forwardControl;!1===e.params?.forwardThinking&&(t.forwardThinking=!1),!1===e.params?.forwardToolCalls&&(t.forwardToolCalls=!1),this.transport.sendResult(e.id,{protocolVersion:ACP_PROTOCOL_VERSION,agentCapabilities:{loadSession:!0,skills:!0,multipleSessions:!0,sessionCapabilities:{"web-ssh":!!this.config.bridge.shell.enabled,canvas:!!this.config.canvasEnabled},promptCapabilities:{image:!0,audio:!1,embeddedContext:!0},mcp:{}},agentInfo:{name:"beeos-claw",title:"BeeOS Agent Bridge",version:"0.1.0"},_meta:{},authMethods:[]})}async handleSessionNew(e){const t=`agent:${"string"==typeof e.params?.agentId&&e.params.agentId||this.config.gateway.agentId||this.getDefaultAgentId()}:${randomUUID()}`,s="string"==typeof e.params?.cwd?e.params.cwd:".",r=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(r,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:r.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:r.agentId,platformAgentId:r.platformAgentId??null}}),this.fetchHistoryInBackground(t)}fetchHistoryInBackground(e){const t=this.getGateway();t?.isConnected&&loadHistory(t,e,50).catch(e=>{this.logger.debug?.(`[bridge-core] Background history fetch failed: ${e instanceof Error?e.message:e}`)})}async subscribeSessionMessages(e){if(e.messageSubscriptionActive)return;const t=this.getGateway();if(t?.isConnected)try{await t.sessionsMessagesSubscribe(e.gatewaySessionKey),e.messageSubscriptionActive=!0,this.logger.debug?.(`[bridge-core] session.message subscribed sessionKey=${e.gatewaySessionKey}`)}catch(t){this.logger.debug?.(`[bridge-core] session.message subscribe failed key=${e.gatewaySessionKey}: ${t instanceof Error?t.message:t}`)}}async resubscribeAllSessionMessages(){for(const e of this.state.sessions.values())e.messageSubscriptionActive=!1,await this.subscribeSessionMessages(e)}handleSessionMessage(e){const t=e?.sessionKey,s=e?.messageId;if(!t||!s)return;const r=this.state.sessionsByGatewayKey.get(t);if(!r)return;const n=e.message?.role;if("toolResult"===n||"tool"===n)return void this.handlePersistedToolResultMessage(r,e).catch(e=>{this.logger.warn?.(`[bridge-core] persisted toolResult handling failed sessionKey=${t} messageId=${s}: ${e instanceof Error?e.message:String(e)}`)});const i="user"===n?"user":"system"===n?"system":"assistant",o=r.lastTurnIdByRole?.[i];if(!o)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${i} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:o};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(r.sessionId,{sessionUpdate:"message_id_bind",role:i,messageId:s,...l?{timestamp:l}:{}},d),r.lastTurnIdByRole&&(r.lastTurnIdByRole[i]=void 0),"assistant"===i&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:()=>this.getCanvasState?.()??null,isSessionAlive:e=>this.state.sessions.has(e)},{session:r,turnId:o,messageId:s,runId:void 0})}async handlePersistedToolResultMessage(e,t){const s=t.messageId;if(!s)return;const r=extractPersistedToolResultEnvelope(t.message),n=this.extractPersistedToolResultFromHistory(e,s),i=mergePersistedToolResultEnvelopes(n,r);if(!i?.toolCallId)return void this.writeObs({component:"bridge-core",domain:"tool_result",name:"tool_result.skipped_no_tool_call_id",severity:"warn",payload:{sessionId:e.sessionId,messageId:s}});n&&this.writeObs({component:"bridge-core",domain:"tool_result",name:"tool_result.hydrated_from_history",severity:"debug",payload:{sessionId:e.sessionId,messageId:s,toolCallId:i.toolCallId,toolName:i.toolName}});const o=e.toolCallTurns?.get(i.toolCallId),a=o?.turnId??resolveTurnId(e);if(!a)return void this.writeObs({component:"bridge-core",domain:"tool_result",name:"tool_result.skipped_no_turn",severity:"warn",payload:{sessionId:e.sessionId,messageId:t.messageId,toolCallId:i.toolCallId,toolName:i.toolName}});const l=projectPersistedToolResultForReplay({toolCallId:i.toolCallId,toolName:i.toolName||o?.toolName||"tool",...i.rawText?{rawText:i.rawText}:{},...i.rawResult?{rawResult:i.rawResult}:{},...i.timestamp?{timestamp:i.timestamp}:{}});await emitToolResultProjectionForTurn({session:e,projection:l,toolCallId:i.toolCallId,turnId:a,config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:()=>this.getCanvasState?.()??null,sendUpdate:s=>(a&&void 0===s._turnId&&(s._turnId=a),this.transport.sendSessionUpdate(e.sessionId,s,{turnId:a,..."number"==typeof t.messageSeq?{seq:t.messageSeq}:{}}))})}extractPersistedToolResultFromHistory(e,t){if(!e.agentId)return;const s=this.localSessionHistory.readEntries({agentId:e.agentId,sessionKey:e.gatewaySessionKey,sessionId:e.sessionId});return findPersistedToolResultEnvelopeByMessageId(s,t)}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const r=s.sessions;if(!Array.isArray(r))return;for(const t of r){const s=t,r=s.sessionKey??s.key??s.id??s.name;if(r&&"string"==typeof r&&deriveSessionId(r,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,n=createSessionObject({gatewayKey:r,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(n),n}}}catch(e){this.logger.debug?.("[bridge-core] recoverSessionFromGateway failed:",e)}}async resolveOrCreateSession(e){let t=this.state.sessions.get(e);if(!t&&e.startsWith("agent:")&&(t=this.state.findSessionByKey(e)),t||e.startsWith("agent:")||(t=await this.recoverSessionFromGateway(e)),t)return{session:t,created:!1};const s=e.startsWith("agent:"),r=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${r}:${e}`,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels}),this.registerSession(t),{session:t,created:!0}}async handleSessionLoad(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const{session:s}=await this.resolveOrCreateSession(t),r=s.sessionId;this.state.lastActiveSessionId=r,s.chatReplayDedupKeys.clear(),s.sessionLoadInProgress=!0;try{const e=await this.emitHistoryTurns(s);this.emitCurrentStreamSnapshot(s,e)}finally{s.sessionLoadInProgress=!1}this.transport.sendResult(e.id,{sessionId:r}),this.onSessionLoaded?.(r,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})}async emitHistoryTurns(e){const t=new Set,s=e.sessionId;try{const r=this.getGateway(),n=e.assistantMq&&!e.assistantMq.closed,i=n?null:r,o=await loadHistory(i,e.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:e.agentId,sessionId:e.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}}),a=groupIntoTurns(o),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,g=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${o.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${g}`),l>0&&e.pendingUserTurnUpdates&&e.pendingUserTurnUpdates.length>0&&(e.pendingUserTurnUpdates=[]),a.length>0&&isPlaceholderTitle(e.title)&&"manual"!==e.titleSource){const t=a.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=cleanHistoryUserContent(e.content);return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const r=cleanHistoryUserContent(t.content).slice(0,64).replace(/\n/g," ").trim();if(r){e.title=r,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:r}),this.tryGenerateTitleFromHistory(e,s,a)}}}const p=t=>`history:${e.gatewaySessionKey}:${t}`;let m=-1;if(n)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){m=e;break}const{replayedToolCallIds:h,streamCompleteCount:u}=await this.replayHistoryTurnsToSink(e,a,this.transport,{mqActive:Boolean(n),lastAsstIdx:m,turnIdFor:(e,t)=>p(t)});for(const e of h)t.add(e);this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${u} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}async replayHistoryTurnsToSink(e,t,s,r){const n=e.sessionId,i=new Set;let o=0;for(let a=0;a<t.length;a++){const l=t[a],d=r.turnIdFor(l,a);if(r.mqActive&&a===r.lastAsstIdx){const e=classifyHistoricalAssistantTurnCompletion(l,{mqActive:r.mqActive,isSkippedForLiveMq:!0});this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] assistant — ${"live"===e.kind?e.reason:"mq active"}, deferring to emitCurrentStreamSnapshot`);continue}if("user"===l.role&&"internal_system"===l.provenance?.kind){this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] internal_system user message`);continue}if("user"===l.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(l.content)){this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] system-injected user message`);continue}if("user"===l.role&&isHeartbeatPromptText(l.content)){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewSanitizedText(l.content)}"`);continue}if("user"===l.role&&isCronPromptText(l.content)){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] cron-triggered user message sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewSanitizedText(l.content)}"`);continue}if("assistant"===l.role){const t=classifyHeartbeatFilter(l.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewSanitizedText(l.content)}"`);continue}}const c="assistant"===l.role?classifyHistoricalAssistantTurnCompletion(l,{mqActive:r.mqActive,isSkippedForLiveMq:!1}):void 0;if("error"===c?.kind||"interrupted"===c?.kind&&c.failure)s.sendSessionUpdate(n,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},...l.timestamp?{timestamp:l.timestamp}:{}},{turnId:d,...void 0!==l._seq?{seq:l._seq}:{}}),s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:c.stopReason,error:c.message,errorCode:c.errorCode},{turnId:d}),this.logger.info?.(`[bridge-core] history_runtime_failure_terminalized session_id=${n} turn_id=${d} error_code=${c.errorCode} stop_reason=${c.stopReason} source=history_replay`),o+=1;else if("system"!==l.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${a}] role=${l.role} contentLen=${l.content.length} content=${JSON.stringify(l.content.slice(0,80))} tcCount=${l.toolCalls?.length??0}`),"user"===l.role)this.replayUserTurnAsChunks(s,n,l.content,l.metadata,l.timestamp,d,l._seq);else{if(await this.replayAssistantTurnAsStream(s,n,l,d),l.toolCalls)for(const e of l.toolCalls)e.id&&i.add(e.id);const e=c??classifyHistoricalAssistantTurnCompletion(l,{mqActive:r.mqActive,isSkippedForLiveMq:!1});if("interrupted"===e.kind){const t=new Set(e.incompleteToolCallIds);for(const r of l.toolCalls??[])t.has(r.id)&&s.sendSessionUpdate(n,buildToolCallUpdatePayload({toolCallId:r.id,toolName:r.name,outputText:e.message,kind:r.kind,status:"failed",timestamp:l.timestamp}),{turnId:d});this.logger.info?.(`[bridge-core] history_orphan_turn_terminalized session_id=${n} turn_id=${d} tool_call_ids=${e.incompleteToolCallIds.join(",")} stop_reason=${e.stopReason} source=history_replay`),s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:e.stopReason,error:e.message,errorCode:e.errorCode},{turnId:d}),o+=1;continue}if("completed"!==e.kind)continue;s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:e.stopReason},{turnId:d}),o+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${a}] contentLen=${l.content.length}`),s.sendSessionUpdate(n,{sessionUpdate:"system_message_chunk",content:{type:"text",text:l.content},metadata:l.metadata,...l.timestamp?{timestamp:l.timestamp}:{}},{turnId:d,...void 0!==l._seq?{seq:l._seq}:{}})}return{replayedToolCallIds:i,streamCompleteCount:o}}async handleSessionHistoryPage(e){const t=e.params??{},s="string"==typeof t.sessionId?t.sessionId:"";if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const r="string"==typeof t.cursor&&t.cursor.length>0?t.cursor:void 0,n="number"==typeof t.limit&&Number.isFinite(t.limit)&&t.limit>0?Math.min(1e3,Math.floor(t.limit)):100,{session:i}=await this.resolveOrCreateSession(s),o=this.getGateway(),a=await loadHistoryPage(o,i.gatewaySessionKey,{...r?{cursor:r}:{},limit:n,logger:this.logger}),l=groupIntoTurns(a.messages),d=new HistoryUpdateRecorder(this.transport);await this.replayHistoryTurnsToSink(i,l,d,{mqActive:!1,lastAsstIdx:-1,turnIdFor:(e,t)=>void 0!==e._seq?`history:${i.gatewaySessionKey}:seq:${e._seq}`:`history:${i.gatewaySessionKey}:seq:none:${r??"head"}:${t}`}),this.logger.debug?.(`[bridge-core] _beeos/session/history sid=${i.sessionId} cursor=${r??"<head>"} limit=${n} turns=${l.length} updates=${d.updates.length} hasMore=${a.hasMore} nextCursor=${a.nextCursor??"<none>"}`),this.transport.sendResult(e.id,{updates:d.updates,...a.nextCursor?{nextCursor:a.nextCursor}:{},hasMore:a.hasMore})}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const r=e.assistantMq;if(r&&!r.closed){const s=r.peek(0);let n=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const r=s[e],i=r.sessionUpdate,o=r.toolCallId;if("tool_call"!==i&&"tool_call_update"!==i||!o||!t.has(o)){if("agent_message_chunk"===i){if(""===(r.content?.text??"").trim()&&e<s.length-1){const r=s[e+1];if(("tool_call"===r.sessionUpdate||"tool_call_update"===r.sessionUpdate)&&r.toolCallId&&t.has(r.toolCallId)){n=e+1;continue}}break}break}n=e+1}const i=resolveTurnId(e),o=i?{turnId:i}:void 0;for(let t=n;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],o)}}async handleSessionList(e){const t=[];for(const[,e]of this.state.sessions)isBackgroundSessionKey(e.gatewaySessionKey)||this.cronIsolatedSessionKeys.has(e.gatewaySessionKey)||t.push({sessionId:e.sessionId,title:e.title||"New chat",cwd:e.cwd,updatedAt:e.lastStreamActivityTs??e.createdAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(e.gatewaySessionKey)});const s=this.getGateway();if(s?.isConnected)try{const e=await s.sessionsList();if(e&&"object"==typeof e&&Array.isArray(e.sessions)){const s=e.sessions;for(const e of s){const s=e,r=s.sessionKey??s.key??s.id??s.name;if(!r||"string"!=typeof r)continue;if(isBackgroundSessionKey(r))continue;if(this.cronIsolatedSessionKeys.has(r))continue;const n=deriveSessionId(r,this.instanceId),i=t.find(e=>e.sessionId===r||e.sessionId===n||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===r),o=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=o&&!isPlaceholderTitle(o)?o:void 0,l=s.model,d=s.modelProvider;if(i){if(a&&isPlaceholderTitle(i.title)){i.title=a;const e=this.state.sessions.get(i.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(i.updatedAt=e),l&&(i.model=l),d&&(i.modelProvider=d)}else{const e=createSessionObject({gatewayKey:r,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s.cwd,title:a});this.registerSession(e,{ensureVerbose:!1}),t.push({sessionId:e.sessionId,title:a||"",cwd:s.cwd,updatedAt:s.updatedAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(r),model:l,modelProvider:d})}}}}catch(e){this.logger.debug?.("[bridge-core] sessions.list enrichment failed:",e)}t.sort((e,t)=>(t.updatedAt??0)-(e.updatedAt??0)||e.sessionId.localeCompare(t.sessionId)),this.transport.sendResult(e.id,{sessions:t})}handleSessionRename(e){const t=e.params?.sessionId,s=e.params?.title;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, title"});const r=s.slice(0,64),n=this.state.sessions.get(t);if(!n)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);n.title=r,n.titleSource="manual";const i=n.gatewaySessionKey,o=this.getGateway();o?.isConnected&&o.sessionsPatch(i,{label:r}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:r})}static TITLE_TEMPLATES=[{match:["今日日报","日报"],zh:"今日工作日报",en:"Daily Work Log"},{match:["周报","周报总结","本周周报"],zh:"本周工作周报",en:"Weekly Work Report"},{match:["月报","月度汇报","月度总结"],zh:"本月工作月报",en:"Monthly Work Report"},{match:["工作日志","日志"],zh:"工作日志整理",en:"Work Log Review"},{match:["晨会","今日晨会"],zh:"今日晨会纪要",en:"Daily Standup Notes"},{match:["复盘","今日复盘"],zh:"今日工作复盘",en:"Daily Retrospective"},{match:["新会话","new chat","new conversation"],zh:"新会话",en:"New Conversation"}];static TITLE_SYSTEM_PROMPT='You are a conversation title generator. Your task is to generate a concise, human-readable title summarizing the topic of the user\'s question together with the assistant\'s answer.\n\n### Rules:\n- Length: 3-6 words in English, or 6-20 characters in Chinese (up to 24 if needed to keep all key domain terms intact).\n- Language: Match the user\'s input language exactly. Mixed zh/en input → use the dominant language.\n- Extract the core topic (noun-centric) of the Q&A exchange, not the action verb.\n- Use the assistant\'s answer to disambiguate or enrich the topic when the question alone is vague (e.g. "帮我看下" + an answer about x402 → "x402 支付集成").\n- No punctuation, no quotation marks, no emojis, no markdown.\n- No prefix like "Title:" or "Here is". Output the title only.\n- Avoid generic words: "help", "question", "chat", "problem", "task".\n- If both the question and answer are too vague to summarize, return "新会话" (for zh) or "New Conversation" (for en).\n\n### Input Format:\nThe user message will contain both the question and the assistant\'s answer, delimited like this:\n[User Question]\n...question text...\n\n[Assistant Answer]\n...answer text (may be truncated with "..." in the middle)...\n\n### Output Format:\nRespond ONLY with valid JSON, no other text:\n{"title": "<your title here>"}\n\n### Examples:\nInput:\n[User Question]\n帮我写一个关于BeeOS设备接入的PRD\n\n[Assistant Answer]\n好的,这是 BeeOS 设备接入 PRD 的草稿:1. 目标 ...\nOutput: {"title": "BeeOS 设备接入 PRD"}\n\nInput:\n[User Question]\n帮我看下\n\n[Assistant Answer]\n你说的 x402 支付协议是一个基于 HTTP 402 的链上支付标准 ...\nOutput: {"title": "x402 支付协议解析"}\n\nInput:\n[User Question]\nWhat\'s the best way to integrate x402 payment protocol?\n\n[Assistant Answer]\nYou have three options: 1. Use the official SDK ...\nOutput: {"title": "x402 Payment Integration"}\n\nInput:\n[User Question]\nhi\n\n[Assistant Answer]\nHello! How can I help you today?\nOutput: {"title": "New Conversation"}';matchTitleTemplate(e){const t=e.trim().toLowerCase();for(const s of AcpGatewayBridgeCore.TITLE_TEMPLATES)if(s.match.some(e=>e.toLowerCase()===t))return/[\u4e00-\u9fff]/.test(e)?s.zh:s.en}isGuardMessage(e){const t=e.trim();return[...t].length<3||[...t].every(e=>/[\p{P}\p{S}\s]/u.test(e))}truncateQuestionForTitle(e){return e.length>2e3?e.slice(0,1500):e}truncateAnswerForTitle(e){return e.length<=1e3?e:`${e.slice(0,500)}\n...\n${e.slice(-500)}`}generateTitleFromQA(e,t,s,r){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const n=this.matchTitleTemplate(s);if(n){e.title=n,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:n})}const i=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),o=process.env.AROUTER_API_KEY||"";if(!i||!o)return void this.logger.info?.(`[bridge-core] title-gen SKIP sid=${t}: no AROUTER env vars`);const a=`[User Question]\n${this.truncateQuestionForTitle(s)}\n\n[Assistant Answer]\n${this.truncateAnswerForTitle(r)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${r.length}`),fetch(`${i}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify({model:"openai/gpt-4o-mini",max_tokens:32,temperature:.3,messages:[{role:"system",content:AcpGatewayBridgeCore.TITLE_SYSTEM_PROMPT},{role:"user",content:a}]})}).then(async s=>{if(!s.ok){const e=await s.text().catch(()=>"");return void this.logger.info?.(`[bridge-core] title-gen FAILED sid=${t} status=${s.status} body=${e.slice(0,300)}`)}const r=await s.json(),n=r.choices?.[0]?.message?.content?.trim();if(!n)return;const i=n.replace(/^```json\s*/,"").replace(/```$/,"").trim();let o;try{o=JSON.parse(i).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${i}`)}if(!o)return;if("manual"===e.titleSource)return void this.logger.debug?.("[bridge-core] title-gen discarded: user already renamed");this.logger.info?.(`[bridge-core] title-gen OK sid=${t} title="${o}"`),e.title=o,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:o}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:o})}).catch(e=>{this.logger.info?.(`[bridge-core] title-gen ERROR sid=${t}: ${e instanceof Error?e.message:e}`)})}async tryGenerateTitleFromHistory(e,t,s){if("manual"===e.titleSource||"auto"===e.titleSource)return;const r=this.getGateway();if(!r?.isConnected)return;let n=s;if(!n)try{const t=await loadHistory(r,e.gatewaySessionKey);n=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const i=n.find(e=>{if("user"!==e.role)return!1;if(!e.content?.trim())return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=cleanHistoryUserContent(e.content);return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!i)return;const o=n.indexOf(i),a=n.slice(o+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=cleanHistoryUserContent(i.content),d=a.content.trim();l&&d&&this.generateTitleFromQA(e,t,l,d)}async handleSessionDelete(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.info?.(`[bridge-core] session/delete sessionId=${t} NOT FOUND in local state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);const r=s.gatewaySessionKey,n=this.sessionMutationCoordinator.tryStartExclusive({sessionKey:r,label:"session_delete"},"acp");if(!n.ok)return void this.sendSessionBusy(s,e.id,"active_session_mutation",n.queueDepth);const i=n.token;try{if(this.hasActiveRun(s)){const t=this.sessionMutationCoordinator.gateSnapshot(r);return void this.sendSessionBusy(s,e.id,"active_session_mutation",t.queueDepth)}this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${r} gwConnected=${!!this.getGateway()?.isConnected}`);let n=!1;const i=this.getGateway();if(i?.isConnected&&r)try{await i.sessionsDelete(r),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${r}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${r}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await i.sessionsReset(r),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${r}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${r}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else r&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${r}`);this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const o=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),o.add(e));for(const[e,t]of this.state.toolCallIdMap)o.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(r),this.transport.resetSessionUpdateCount(t),n&&this.onSessionDeleted?.(r),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${r} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}finally{this.sessionMutationCoordinator.releaseBeforeRuntime(i)}}async handleSessionClear(e){this.logger.info?.(`[bridge-core] handleSessionClear called, params=${JSON.stringify(e.params)}`);const t=e.params?.sessionId;if(!t)return this.logger.warn?.("[bridge-core] session/clear missing sessionId"),void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.warn?.(`[bridge-core] session/clear sessionId=${t} not found in state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);this.logger.info?.(`[bridge-core] session/clear sessionId=${t} gatewayKey=${s.gatewaySessionKey}`);const r=this.getGateway(),n=r?.isConnected;if(!s.promptDone&&(void 0!==s.pendingPromptId||void 0!==s.currentRunId)){this.logger.info?.(`[bridge-core] session/clear: cancelling active run sid=${t} runId=${s.currentRunId}`);const i=s.writeGateToken;let o=!1;if(n&&i){const e=await this.sessionMutationCoordinator.requestCancel(i,r);o="recovery_required"===await this.handleCancellationResult(s,e)}else n&&await r.chatAbort(s.gatewaySessionKey,s.currentRunId);return void this.transport.sendError(e.id,Errors.AgentError.rpcCode,o?SESSION_RECOVERY_REQUIRED_MESSAGE:"Cancellation is still converging",o?{code:SESSION_RECOVERY_REQUIRED_CODE,stopReason:"interrupted"}:{code:"session_cancellation_pending",stopReason:"cancelled"})}if(n&&s.gatewaySessionKey)try{await r.sessionsReset(s.gatewaySessionKey),this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${s.gatewaySessionKey}`)}catch(e){this.logger.warn?.(`[bridge-core] Gateway sessions.reset FAILED for key=${s.gatewaySessionKey}: ${e instanceof Error?e.message:e}`)}else this.logger.warn?.(`[bridge-core] session/clear skipped gateway reset: connected=${n}, key=${s.gatewaySessionKey}`);s.chatReplayDedupKeys.clear(),this.logger.info?.("[bridge-core] session/clear dedupKeys cleared, calling onSessionCleared"),this.onSessionCleared?.(t,s.gatewaySessionKey),this.transport.sendResult(e.id,{cleared:!0}),this.logger.info?.("[bridge-core] session/clear DONE, sent cleared=true")}async handleSessionSetMode(e){const t=e.params?.sessionId,s=e.params?.mode,r=t?this.state.sessions.get(t):void 0,n=this.getGateway();if(r&&n?.isConnected&&s){const e={};if("verbose"===s||"detailed"===s?e.verboseLevel="full":"concise"!==s&&"default"!==s||(e.verboseLevel="default"),"reasoning"!==s&&"thinking"!==s||(e.reasoningLevel="stream"),Object.keys(e).length>0)try{await n.sessionsPatch(r.gatewaySessionKey,e)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch failed: ${e instanceof Error?e.message:e}`)}}this.transport.sendResult(e.id,{mode:s??"default"})}async handleSessionSetModel(e){const t=e.params?.sessionId,s=e.params?.model,r=t?this.state.sessions.get(t):void 0,n=this.getGateway();let i=s??null,o=null;if(r&&n?.isConnected&&s)try{const e=await n.sessionsPatch(r.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(i=t.model),t?.modelProvider&&(o=t.modelProvider)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch for model failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{model:i,modelProvider:o})}async createAgentShell(e,t,s){const r=this.getGateway();if(!r?.isConnected)throw new Error("gateway not connected");const n=`agent-${randomUUID().replace(/-/g,"").slice(0,12)}`,i=n,o=e,a=join(this.getOpenclawHome(),`workspace-${n}`),l=await r.request("agents.create",{name:i,workspace:a,...s?{model:s}:{},...t?.emoji?{emoji:t.emoji}:{},...t?.avatar?{avatar:t.avatar}:{}}),d=l?.agentId??n,c=l?.workspace??a;if(o)try{await r.request("agents.update",{agentId:d,name:o})}catch(e){this.logger.warn?.(`[bridge-core] createAgentShell: failed to set display name for "${d}": ${e instanceof Error?e.message:e}`)}return{agentId:d,workspace:c}}async handleAgentCreate(e){const t=e.params,s=(t?.name??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});const r=this.getGateway();if(r?.isConnected)try{const{agentId:n,workspace:i}=await this.createAgentShell(s,t?.identity,t?.model),o=t?.files??{};for(const[e,t]of Object.entries(o))if("IDENTITY.md"!==e){if("string"==typeof t)try{await r.request("agents.files.set",{agentId:n,name:e,content:t})}catch(t){this.logger.warn?.(`[bridge-core] agent/create: failed to write "${e}" for "${n}": ${t instanceof Error?t.message:t}`)}}else this.logger.warn?.("[bridge-core] agent/create: ignoring IDENTITY.md in files (identity is set via structured fields)");this.logger.info?.(`[bridge-core] Created agent "${n}" (name="${s}") workspace=${i}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/create: agent sync after create failed: ${e instanceof Error?e.message:e}`)}const a=this.agentMapping?.get(n)?.id??"";this.transport.sendResult(e.id,{agentId:n,platformAgentId:a,workspace:i})}catch(t){this.logger.error?.(`[bridge-core] Failed to create agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Create agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleApplyTemplate(e){const t=e.params,s=(t?.name??"").trim(),r=(t?.packageUrl??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});if(!r)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"packageUrl"});const n=this.getGateway();if(n?.isConnected)try{const n=await fetch(r);if(!n.ok)throw new TemplatePackageError(`failed to download package: HTTP ${n.status}`);const i=Buffer.from(await n.arrayBuffer());if(i.length>MAX_ZIP_BYTES)throw new TemplatePackageError(`package too large: ${i.length} bytes (max ${MAX_ZIP_BYTES})`);const o=t?.sha256?.trim().toLowerCase();if(o){if(createHash("sha256").update(i).digest("hex")!==o)throw new TemplatePackageError("package sha256 mismatch")}const a=parseAndValidateTemplatePackage(i),{agentId:l,workspace:d}=await this.createAgentShell(s,t?.identity,t?.model);let c=0;for(const[e,t]of a.workspaceFiles){const s=resolve(d,e),r=relative(d,s);if(""===r||r.startsWith("..")||path.isAbsolute(r))throw new TemplatePackageError(`refusing to write outside workspace: ${e}`);await mkdir(path.dirname(s),{recursive:!0}),await writeFile(s,t),c++}const g=a.manifest?.slug??"(no manifest)",p=a.manifest?.version??"";this.logger.info?.(`[bridge-core] agent/applyTemplate: applied "${g}${p?`@${p}`:""}" to agent "${l}" (${c} files) workspace=${d}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/applyTemplate: agent sync after apply failed: ${e instanceof Error?e.message:e}`)}const m=this.agentMapping?.get(l)?.id??"";this.transport.sendResult(e.id,{agentId:l,platformAgentId:m,workspace:d})}catch(t){if(t instanceof TemplatePackageError)return this.logger.warn?.(`[bridge-core] agent/applyTemplate: invalid package: ${t.message}`),void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:`Invalid template package: ${t.message}`});this.logger.error?.(`[bridge-core] Failed to apply template for "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Apply template failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentUpdate(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});const r=t?.name?.trim(),n=t?.model?.trim(),i=t?.identity?.emoji?.trim(),o=t?.identity?.avatar?.trim();if(!(r||n||i||o))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"No updatable fields provided (name/model/identity)"});const a=this.getGateway();if(a?.isConnected)try{const t=await a.request("agents.update",{agentId:s,...r?{name:r}:{},...n?{model:n}:{},...i?{emoji:i}:{},...o?{avatar:o}:{}}),l=t?.agentId??s;this.logger.info?.(`[bridge-core] Updated agent "${l}"${r?` name="${r}"`:""}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/update: agent sync after update failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:l})}catch(t){this.logger.error?.(`[bridge-core] Failed to update agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentDelete(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});if("main"===normalizeAgentId(s))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:'"main" cannot be deleted; delete the instance instead'});const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const n=t?.deleteFiles??!0;try{const t=await r.request("agents.delete",{agentId:s,deleteFiles:n}),i=t?.agentId??s;this.logger.info?.(`[bridge-core] Deleted agent "${i}" (deleteFiles=${n})`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/delete: agent sync after delete failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:i,..."number"==typeof t?.removedBindings?{removedBindings:t.removedBindings}:{}})}catch(t){this.logger.error?.(`[bridge-core] Failed to delete agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Delete agent failed: ${t instanceof Error?t.message:String(t)}`})}}async handleAgentList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("agents.list",{}),r=(s?.agents??[]).filter(e=>"string"==typeof e.id&&e.id.length>0).map(e=>{const t=e.identity?.avatarUrl||e.identity?.avatar;return{agentId:e.id,name:e.identity?.name||e.name||e.id,...e.identity?.emoji?{emoji:e.identity.emoji}:{},...t?{avatarUrl:t}:{}}}),n={...s?.defaultId?{defaultId:s.defaultId}:{},agents:r};this.transport.sendResult(e.id,n)}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list agents: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleModelsList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("models.list",{});this.transport.sendResult(e.id,s??{models:[]})}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list models: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleSkillsList(e){const t=e.params?.agentId,s=await this.fetchSimplifiedSkills(t);this.transport.sendResult(e.id,{skills:s})}async fetchSimplifiedSkills(e){const t=this.getGateway();if(!t?.isConnected)return[];try{const s=await t.skillsStatus(e);if(!s?.skills)return[];const r=s.skills.filter(e=>!1!==e.eligible||!0===e.disabled).sort((e,t)=>e.always&&!t.always?-1:!e.always&&t.always?1:e.name.localeCompare(t.name)).map(e=>({name:e.name,description:e.description,emoji:e.emoji,always:e.always||void 0,bundled:e.bundled||void 0,disabled:e.disabled||void 0,skillKey:e.skillKey||void 0,iconUrl:void 0})),n=await this.getSkillsDir(e);for(const e of r){const t=e.skillKey??e.name;try{const s=await readFile(join(n,t,".beeos-meta.json"),"utf8"),r=JSON.parse(s);r?.iconUrl&&(e.iconUrl=r.iconUrl)}catch{}}return r}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(e){const t=await this.fetchSimplifiedSkills(e);this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:t}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:t.length}})}getOpenclawHome(){return process.env.OPENCLAW_HOME||join(homedir(),".openclaw")}async getSkillsDir(e){const t=this.getOpenclawHome();if(!e||"main"===e)return join(t,"skills");const s=await this.resolveAgentWorkspaceDir(e);return join(s??join(t,`workspace-${e}`),"skills")}async resolveAgentWorkspaceDir(e){const t=this.getGateway();if(!t?.isConnected)return null;try{const s=await t.request("agents.list",{}),r=normalizeAgentId(e),n=s?.agents?.find(e=>normalizeAgentId(e.id)===r);return n?.workspace??null}catch(t){return this.logger.warn?.(`[bridge-core] resolveAgentWorkspaceDir("${e}") failed: ${t instanceof Error?t.message:t}`),null}}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,r=t?.files,n=t?.agentId;let i;try{i=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!r?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(await this.getSkillsDir(n),i);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(r?.length){const e=resolve(o);for(const t of r){const s=join(o,t.path);if(!resolve(s).startsWith(e+sep))throw new Error(`File path escapes skill directory: ${t.path}`);await mkdir(join(s,".."),{recursive:!0});const r=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!r.ok)throw new Error(`Failed to download ${t.path}: ${r.status}`);const n=Buffer.from(await r.arrayBuffer());await writeFile(s,n)}}if(t?.iconUrl)try{await writeFile(join(o,".beeos-meta.json"),JSON.stringify({iconUrl:t.iconUrl}),"utf8")}catch{}this.logger.info?.(`[bridge-core] Installed skill "${i}"${n?` for agent "${n}"`:""} via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList(n)}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${i}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Install failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUninstall(e){const t=e.params,s=t?.agentId;let r;try{r=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const n=resolveSkillInstallDir(await this.getSkillsDir(s),r),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),r);try{let t=!1;try{const e=await lstat(n);if(e.isSymbolicLink()){const e=await readlink(n);await rm(n,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(n,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(i);(e.isDirectory()||e.isSymbolicLink())&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${r}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${r}"${s?` for agent "${s}"`:""}`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList(s)}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${r}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Uninstall failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUpdate(e){const t=e.params,s=t?.skillKey,r=t?.enabled,n=t?.agentId;if(!s||"boolean"!=typeof r)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const i=this.getGateway();if(i?.isConnected)try{const t=await i.skillsUpdate(s,r,n);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${r}${n?` for agent "${n}"`:""}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:r}),await this.pushSkillsList(n)}catch(t){this.logger.error?.(`[bridge-core] Failed to update skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.InternalError,{message:"Gateway not connected"})}async handleSessionPrompt(e){const t=e.params?.sessionId,s=e.params?.prompt;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, prompt"});const{session:r,created:n}=await this.resolveOrCreateSession(t);n&&this.onSessionLoaded?.(r.sessionId,r.gatewaySessionKey,r.platformAgentId);const i=r.sessionId;this.state.lastActiveSessionId=i,this.onPromptStart?.();const o=this.getGateway();if(!o?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);if(this.hasActiveRun(r)){const t=this.sessionMutationCoordinator.gateSnapshot(r.gatewaySessionKey);return void this.sendSessionBusy(r,e.id,"active_run",t.queueDepth)}try{await this.ensureVerboseFull(r)}catch(t){return this.releaseSessionWriteGate(r),void this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to prepare agent context: ${t instanceof Error?t.message:String(t)}`})}let a=extractPromptBlocks(sanitizePromptPayload(s));const l=a.filter(e=>"text"===e.type&&"text"in e&&"string"==typeof e.text).map(e=>e.text).join("\n");if(r.fileArtifactDisplayMode=prefersChatFileArtifacts(l)?"chat":"auto",!r.title||isPlaceholderTitle(r.title)){const e=a.find(e=>"text"===e.type&&e.text);if(e&&"text"in e&&e.text){const t=stripTransportMetadata(e.text).trim();if(this.logger.info?.(`[bridge-core] title-gen sid=${i} existing="${r.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();r.title=e,r.titleSource="truncated",o?.isConnected&&e&&o.sessionsPatch(r.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(i,{sessionUpdate:"title_update",title:e})}}}const d=buildResolutionPlan(a);if(d.refs.length>0)try{const e=await resolveResources(d,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let c;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{c=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const g=toGatewayPrompt(a,{beeosFileResolutionsByUri:c});if("error"in g)return this.releaseSessionWriteGate(r),void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:g.error});const{message:p,attachments:m}=g,h=resolveStandaloneSlashCommandName(a,p)??resolveSlashCmd(a,p),u="string"==typeof h;if(h&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(h)}catch(e){return t.logger.warn?.(`[bridge-core] /${h} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(r,a,resolveTurnId(r)),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${h}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(r)}),this.transport.sendResult(e.id,{stopReason:"end_turn"}),void this.releaseSessionWriteGate(r)}const y="chat.send";let f=u?p:withUserMessagePrefix(p);const w=this.config.canvasEnabled&&!u&&!0===r.canvasActive&&!r.channelType;if(w){const e=r.promptsSinceCanvasInjection??0;!r.canvasPromptInjected||e>=10?(f=`${CANVAS_TERM_MAPPING}\n\n${f}`,r.canvasPromptInjected=!0,r.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):r.promptsSinceCanvasInjection=e+1}if(w){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(r.gatewaySessionKey);f=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${f}`}const t=r.referencedSurfaceId;t&&(f=`${CANVAS_UPDATE_HINT}\n[IMPORTANT: The user selected canvas surface "${t}" for editing. You MUST call render_ui with id="${t}" to UPDATE this existing surface. Do NOT create a new surface with a different id.]\n${f}`,r.referencedSurfaceId=void 0)}if(!u){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){r.activeMentions=t;f=`${buildMentionInstruction(t)}\n${f}`,this.logger.debug?.(`[bridge-core] delegation mention instruction injected for ${t.length} agent(s)`)}else r.activeMentions=void 0}if(u&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${i} command=/${h}`),this.hasActiveRun(r)){const t=this.sessionMutationCoordinator.gateSnapshot(r.gatewaySessionKey);return void this.sendSessionBusy(r,e.id,"active_run_after_prepare",t.queueDepth)}const S=this.sessionMutationCoordinator.tryStartInteractive({sessionKey:r.gatewaySessionKey,label:"session/prompt"},"acp");if(!S.ok)return void this.sendSessionBusy(r,e.id,S.reason,S.queueDepth);r.writeGateToken=S.token,this.state.resetForNewRun(r,i,this.transport),r.pendingPromptId=e.id,r.gatewayMethod=y;const I=resolveTurnId(r);this.emitPromptAsUserSessionUpdates(r,a,I),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},I?{turnId:I}:void 0);const v=extractCronJobId(p);v&&(r.cronJobId=v),this.schedulePromptTimeout(r);const E=m.length>0?m.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,_=`req_${randomUUID().replace(/-/g,"")}`,b={sessionKey:r.gatewaySessionKey,message:f,deliver:!1,idempotencyKey:randomUUID()};E?.length&&(b.attachments=E);if(!o.sendFrame({type:"req",id:_,method:y,params:b}))return this.state.clearPromptTimeout(r),r.pendingPromptId=void 0,r.assistantMq?.close(),r.assistantMq=void 0,r.pendingUserTurnUpdates=void 0,this.releaseSessionWriteGate(r),void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});r.gatewayRequestId=_,this.state.promptsByGatewayRequestId.set(_,r)}replayUserTurnAsChunks(e,t,s,r,n,i,o){const a=splitUserReplayBlocks(s),l=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const s of a){let i;if("text"===s.type){if(!s.text)continue;i={type:"text",text:s.text}}else i="resource_link"===s.type?{type:"resource_link",uri:s.uri??"",...s.fileName?{name:s.fileName}:{},...s.mimeType?{mimeType:s.mimeType}:{}}:{type:s.type,...s.mimeType?{mimeType:s.mimeType}:{},...s.fileName?{fileName:s.fileName}:{}};e.sendSessionUpdate(t,{sessionUpdate:"user_message_chunk",content:i,...r?{metadata:r}:{},...n?{timestamp:n}:{}},l)}}async replayAssistantTurnAsStream(e,t,s,r){const n=e=>{if(r||void 0!==e)return{...r?{turnId:r}:{},...void 0!==e?{seq:e}:{}}},i=new Set,o=(s,r,o)=>{if(!s?.length)return;const a=n(o);for(const n of s)n.uri&&!i.has(n.uri)&&(i.add(n.uri),e.sendSessionUpdate(t,{sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:n.uri,name:n.name??"file",...n.mimeType?{mimeType:n.mimeType}:{}},...r?{timestamp:r}:{}},a))},a=n(s._seq);if(!s.sourceMessages?.length){const r={sessionUpdate:"history_turn",role:s.role,content:s.content,reasoning:s.reasoning,toolCalls:s.toolCalls,...s.timestamp?{timestamp:s.timestamp}:{}};return s.metadata&&(r.metadata=s.metadata),e.sendSessionUpdate(t,r,a),void o(s.files,s.timestamp,s._seq)}for(const r of s.sourceMessages){const i=r.updateType??"agent_message_chunk",a=n(r._seq);switch(i){case"agent_message_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:r.content},...r.timestamp?{timestamp:r.timestamp}:{},...r.metadata?{metadata:r.metadata}:{}},a);break;case"agent_thought_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:r.content},...r.timestamp?{timestamp:r.timestamp}:{}},a);break;case"tool_call":{const n=r.toolCallId??"",i=s.toolCalls?.find(e=>e.id===n),o=r.toolName??"tool",l=r.content??"";let d={};if(l)try{d=JSON.parse(l)}catch{}e.sendSessionUpdate(t,buildToolCallPayload({toolCallId:n,toolName:o,args:d,kind:i?.kind,status:i?.status??"in_progress",timestamp:r.timestamp}),a);break}case"tool_call_update":{const n=r.toolCallId??"",i=s.toolCalls?.find(e=>e.id===n),l=r.toolName??i?.name??"tool",d=r.content??i?.output??"",c=await this.resolveReplayResourceLinks(n,l,r.files);e.sendSessionUpdate(t,buildToolCallUpdatePayload({toolCallId:n,toolName:l,outputText:d,kind:i?.kind,status:i?.status??"completed",resourceLinks:c,timestamp:r.timestamp}),a),o(c,r.timestamp,r._seq);break}}}o(s.files,s.timestamp,s._seq)}async resolveReplayResourceLinks(e,t,s){let r=s?.map(e=>({uri:e.uri,name:e.name??"file",...e.mimeType?{mimeType:e.mimeType}:{}}));if(r?.length)return r;if(r=await readEnrichmentCache(e).catch(()=>{}),r?.length)return r;if("beeos_upload_file"!==t)return;const n=await readUploadToolResult(e).catch(()=>{});if(!n)return;const i=buildToolResultSessionContentBlocks(t,n).filter(e=>"resource_link"===e.content?.type&&e.content.uri).map(e=>({uri:e.content.uri,name:e.content.name??"file",...e.content.mimeType?{mimeType:e.content.mimeType}:{}}));return i.length>0?i:void 0}emitPromptAsUserSessionUpdates(e,t,s){const r=(new Date).toISOString(),n=s?{turnId:s}:void 0;for(const i of t){const t=this.toUserSessionUpdateContent(i);if(!t)continue;const o={sessionUpdate:"user_message_chunk",content:t,timestamp:r};s&&void 0===o._turnId&&(o._turnId=s),this.transport.sendSessionUpdate(e.sessionId,o,n),e.pendingUserTurnUpdates?.push(o)}}toUserSessionUpdateContent(e){if("text"===e.type){if(!e.text)return;return{type:"text",text:e.text}}if("image"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.name;if(!e.data&&!e.uri)return;return{type:"image",...e.data?{data:e.data}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource_link"===e.type){if(!e.uri)return;return{type:"resource_link",uri:e.uri,...e.title?{title:e.title}:{},...e.name?{name:e.name}:{},...e.mimeType??e.mime_type?{mimeType:e.mimeType??e.mime_type}:{}}}if("file"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.filename??e.name;if(!e.data&&!e.text&&!e.uri)return;return{type:"file",...e.data?{data:e.data}:{},...e.text?{text:e.text}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource"===e.type){const t=e.resource??{},s=t.uri??e.uri,r=t.mimeType??t.mime_type??e.mimeType??e.mime_type,n=t.text??e.text,i=t.data??e.data,o=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!n&&!i)return;return{type:"resource",resource:{...s?{uri:s}:{},...r?{mimeType:r}:{},...n?{text:n}:{},...i?{data:i}:{},...o?{fileName:o}:{}}}}}async handleSessionCancel(e,t){const s=t&&"object"==typeof t&&"sessionId"in t?t.sessionId:void 0;if(!s)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.MissingParam,{param:"sessionId"}));const r=this.state.sessions.get(s);if(!r)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const n=this.getGateway(),i=r.writeGateToken;if(!n?.isConnected)return i&&(this.sessionMutationCoordinator.requireRecovery(i,"gateway_disconnected","disconnect"),await this.endRun(r,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE})),void(void 0!==e&&this.transport.sendAcpError(e,Errors.GatewayNotConnected));const o=i?await this.sessionMutationCoordinator.requestCancel(i,n):await this.sessionMutationCoordinator.requestCancelForSession(r.gatewaySessionKey,n);if(!o.ok&&"reason"in o&&"no_active_mutation"===o.reason)return void(void 0!==e&&this.transport.sendResult(e,{}));if(!o.ok&&"reason"in o&&"not_cancellable"===o.reason){if(void 0!==e){const t=this.sessionMutationCoordinator.gateSnapshot(r.gatewaySessionKey);this.sendSessionBusy(r,e,"exclusive_session_mutation",t.queueDepth)}return}const a=await this.handleCancellationResult(r,o);void 0!==e&&("recovery_required"===a?this.transport.sendError(e,Errors.AgentError.rpcCode,SESSION_RECOVERY_REQUIRED_MESSAGE,{code:SESSION_RECOVERY_REQUIRED_CODE,stopReason:"interrupted"}):"terminal_already_observed"===a?this.transport.sendError(e,Errors.AgentError.rpcCode,"The run is already committing its terminal state",{code:"terminal_committing",stopReason:"interrupted"}):this.transport.sendResult(e,{}))}hasActiveRun(e){return!e.promptDone&&(void 0!==e.pendingPromptId||void 0!==e.currentRunId||void 0!==e.gatewayRequestId)}observeLifecycle(e,t,s){observeLifecyclePromise(e,this.logger,{component:"bridge-core",action:t,...s?{sessionId:s.sessionId,gatewaySessionKey:s.gatewaySessionKey}:{}})}async handleCancellationResult(e,t){return t.ok?(this.observeLifecycle(t.terminalObservation.then(t=>{if(!t.ok)return this.publishCancellationRecovery(e)}),"cancel_terminal_observation",e),"accepted"):"recovery"in t?(await this.publishCancellationRecovery(e),"recovery_required"):"terminal_already_observed"}async publishCancellationRecovery(e){await this.endRun(e,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE})}releaseSessionWriteGate(e){if(!e.writeGateToken)return;const t=e.writeGateToken;e.writeGateToken=void 0,this.sessionMutationCoordinator.releaseBeforeRuntime(t)}logRuntimeFailure(e,t){this.logger.warn?.(`[bridge-core] runtime_failure_classified session_id=${e.sessionId} gateway_session_key=${e.gatewaySessionKey} run_id=${e.currentRunId??""} gateway_request_id=${e.gatewayRequestId??""} error_code=${t.errorCode} stop_reason=${t.stopReason} source=${t.source} runtime=${t.runtimeKind}`)}sendSessionBusy(e,t,s,r){this.logger.warn?.(`[bridge-core] session_write_gate_busy session_id=${e.sessionId} gateway_session_key=${e.gatewaySessionKey} reason=${s} queue_depth=${r}`),this.transport.sendError(t,Errors.AgentError.rpcCode,"Agent is busy",{code:"session_busy",reason:s,queueDepth:r}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"interrupted",error:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_busy"},{turnId:`req:${String(t)}`})}async endRun(e,t){if(e.promptDone||e.mutationTerminalCommitting)return this.logger.debug?.(`[bridge-core] endRun skipped (promptDone=true) sessionId=${e.sessionId} outcome=${t.kind}`),!1;if(void 0===e.pendingPromptId&&void 0===e.currentRunId&&void 0===e.gatewayRequestId)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;e.mutationTerminalCommitting=!0,this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const s=e.writeGateToken;let r,n,i,o,a="timeout"===e.mutationTerminalOverride?{kind:"interrupted",stopReason:"timeout",message:"The agent did not finish before the session timeout.",errorCode:"gateway_timeout"}:t;if(s){const e="complete"===a.kind?"cancelled"===a.stopReason?"cancelled":"completed":"interrupted"===a.kind&&"timeout"===a.stopReason?"timeout":"error",t=await this.sessionMutationCoordinator.converge(s,e);t.ok?r=t.permit:a={kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE}}try{const t=resolveTurnId(e),s=t?{turnId:t}:void 0;e.promptDone=!0;const r=e.currentRunId;if("complete"===a.kind){if(r&&this.onRunComplete){const t=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(r,t,a.stopReason)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:a.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:a.stopReason},s),this.tryGenerateTitleFromHistory(e,e.sessionId)}else"interrupted"===a.kind?(r&&this.onRunFailed&&this.onRunFailed(r,a.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,Errors.AgentError.rpcCode,a.message,{code:a.errorCode??"interrupted",stopReason:a.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:a.stopReason,error:a.message,...a.errorCode?{errorCode:a.errorCode}:{}},s)):(r&&this.onRunFailed&&this.onRunFailed(r,a.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,a.code,a.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:a.message},s))}catch(e){n=e}try{this.cleanupPrompt(e)}catch(e){i=e}finally{e.writeGateToken=void 0,r&&this.sessionMutationCoordinator.release(r)}try{this.onRunEnded?.(e,a)}catch(e){o=e}if(n)throw n;if(i)throw i;if(o)throw o;return!0}schedulePromptTimeout(e){let t;if(this.state.clearPromptTimeout(e),e.cronJobId){const s=this.cronOverrides.get(e.cronJobId)??this.config.cronMaxDurationDefaultMs;t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,s))}else t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{if(e.mutationTerminalCommitting)return;if(void 0===e.pendingPromptId&&void 0===e.currentRunId)return;this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),e.mutationTerminalOverride="timeout";const s=this.getGateway(),r=e.writeGateToken;s?.isConnected&&r?this.observeLifecycle(this.sessionMutationCoordinator.requestCancel(r,s).then(t=>this.handleCancellationResult(e,t)),"prompt_timeout_cancel",e):s?.isConnected?this.observeLifecycle(s.chatAbort(e.gatewaySessionKey,e.currentRunId).then(()=>this.interruptPrompt(e,classifyAgentRuntimeFailure({raw:`Prompt timed out after ${t}ms`,source:"live_gateway",runtimeKind:"openclaw"}))),"prompt_timeout_unowned_cancel",e):r&&(this.sessionMutationCoordinator.requireRecovery(r,"gateway_disconnected","disconnect"),this.observeLifecycle(this.endRun(e,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE}),"prompt_timeout_disconnected",e))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}adoptBackgroundMutation(e,t){const s=this.sessionMutationCoordinator.attachOrStartExternal({sessionKey:e.gatewaySessionKey,label:"background_run"},"acp",t);return s.ok?("owner"===s.ownership&&(e.writeGateToken=s.token),s):(this.logger.error?.(`[bridge-core] background mutation rejected sessionKey=${e.gatewaySessionKey} runId=${t} reason=${s.reason}`),s)}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.delete(t),this.state.runIdToCronJobId.delete(t),this.state.markRunCompleted(t);const e=`tc_${t}_`;for(const[t,s]of this.state.toolCallIdMap)s.startsWith(e)&&this.state.toolCallIdMap.delete(t)}e.gatewayRequestId&&this.state.promptsByGatewayRequestId.delete(e.gatewayRequestId),e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,e.mutationTerminalOverride=void 0,e.mutationTerminalCommitting=void 0,e.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}async completePrompt(e,t){await this.endRun(e,{kind:"complete",stopReason:t})}async interruptPrompt(e,t){this.logRuntimeFailure(e,t),await this.endRun(e,{kind:"interrupted",stopReason:t.stopReason,message:t.publicMessage,errorCode:t.errorCode})}async failPrompt(e,t,s){await this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())e.writeGateToken?(this.sessionMutationCoordinator.requireRecovery(e.writeGateToken,"gateway_disconnected","disconnect"),this.observeLifecycle(this.endRun(e,{kind:"interrupted",stopReason:"interrupted",message:SESSION_RECOVERY_REQUIRED_MESSAGE,errorCode:SESSION_RECOVERY_REQUIRED_CODE}),"gateway_disconnect_recovery",e)):this.observeLifecycle(this.interruptPrompt(e,classifyAgentRuntimeFailure({raw:Errors.GatewayDisconnected.message,source:"live_gateway",runtimeKind:"openclaw"})),"gateway_disconnect_interrupt",e)}handleGatewayResponse(e){const t=e.id;if(!t)return;const s=this.state.promptsByGatewayRequestId.get(t);if(!s||s.promptDone)return;if(!e.ok){const r=e.error,n=r?.message??"gateway returned error";this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${n}`);const i=classifyAgentRuntimeFailure({raw:n,source:"live_gateway",runtimeKind:"openclaw"});return void("interrupted"===i.kind?this.observeLifecycle(this.interruptPrompt(s,i),"gateway_response_interrupt",s):(this.logRuntimeFailure(s,i),this.observeLifecycle(this.failPrompt(s,Errors.SendFailed.rpcCode,i.publicMessage),"gateway_response_failure",s)))}const r=e.payload,n=r?.runId;n?(s.currentRunId=n,this.sessionMutationCoordinator.bindRun(s.writeGateToken,n),s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(n,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${n} gatewayRequestId=${t} sessionId=${s.sessionId}`)):this.logger.debug?.(`[bridge-core] handleGatewayResponse: no runId in payload gatewayRequestId=${t} sessionId=${s.sessionId}`)}maybeCompletePromptFromChatState(e,t){if(e.promptDone||"chat.send"!==e.gatewayMethod)return;const s="string"==typeof t.state?t.state.toLowerCase():void 0;if(s)if("final"===s){const s="string"==typeof t.stopReason?t.stopReason:void 0;this.observeLifecycle(this.completePrompt(e,s||"end_turn"),"chat_state_final",e)}else if("aborted"===s||"cancelled"===s||"cancel"===s)this.observeLifecycle(this.completePrompt(e,"cancelled"),"chat_state_cancelled",e);else if("error"===s){const s=("string"==typeof t.summary?t.summary:void 0)??("string"==typeof t.message?t.message:void 0)??"gateway chat error",r=classifyAgentRuntimeFailure({raw:s,source:"chat_state",runtimeKind:"openclaw"});"interrupted"===r.kind?this.observeLifecycle(this.interruptPrompt(e,r),"chat_state_interrupt",e):(this.logRuntimeFailure(e,r),this.observeLifecycle(this.failPrompt(e,Errors.AgentError.rpcCode,r.publicMessage),"chat_state_failure",e))}}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),r={...e.params??{}},n="cron.update"===s;!n||r.patch&&"object"==typeof r.patch||(r.patch={});const i=n?r.patch:r,o=i._beeos_meta;let a;if(delete i._beeos_meta,"cron.add"!==s&&"cron.update"!==s||!0!==i._beeos_internal)void 0!==i._beeos_internal&&delete i._beeos_internal;else if(delete i._beeos_internal,i.delivery={mode:"none"},"cron.add"===s){const e=await this.allocateCronChannel(i.name??"cron");e&&(i.description=encodeBeeosChannelTag(i.description,e))}else{const e=r.id,t="description"in i,s=i.description;let n,o=decodeBeeosChannelId(s);!o&&e&&(o=this.cronChannelCache.get(e)),!o&&e&&(n=await this.lookupCronJobDescription(e),o=decodeBeeosChannelId(n));let l=!1;o||(o=await this.allocateCronChannel(i.name??"cron"),l=!0),o&&(t?i.description=encodeBeeosChannelTag(s,o):l&&(i.description=encodeBeeosChannelTag(n,o)),a=o)}try{const n=await t.request(s,r),l="cron.list"===s||"cron.status"===s?this.enrichCronResponseWithOverrides(n):n;if(this.transport.sendResult(e.id,l??{}),("cron.add"===s||"cron.update"===s)&&n&&"object"==typeof n){const e=n.id;if(e&&void 0!==o?.maxDurationMs){const t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,o.maxDurationMs));this.cronOverrides.set(e,t)}}if("cron.remove"===s){const t=e.params?.id;t&&(this.cronOverrides.delete(t),this.cronChannelCache.delete(t))}if(("cron.add"===s||"cron.update"===s)&&n&&"object"==typeof n){const e=n.id,t=decodeBeeosChannelId(i.description)??a;e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const r=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${r}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:r})}}enrichCronResponseWithOverrides(e){if(!e||"object"!=typeof e)return e;const t=e,s=e=>{if(!e||"object"!=typeof e)return e;const t=e,s=t.id;if("string"!=typeof s)return e;const r=this.cronOverrides.get(s);return"number"!=typeof r?e:{...t,_beeos_meta:{maxDurationMs:r}}};if(Array.isArray(t.jobs))return{...t,jobs:t.jobs.map(s)};if(t.jobs&&"object"==typeof t.jobs){const e=t.jobs,r={};for(const[t,n]of Object.entries(e))r[t]=s(n);return{...t,jobs:r}}return e}async allocateCronChannel(e){if(this.agentFetch&&this.agentGatewayUrl)try{const t=`${this.agentGatewayUrl}/api/v1/channels`,s=await this.agentFetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({participants:[this.instanceId,"service:cron"],metadata:{source:"cron",job_name:e}})});if(!s.ok)return void this.logger.warn?.(`[bridge-core] channel alloc failed: ${s.status}`);return(await s.json()).id}catch(e){return void this.logger.warn?.(`[bridge-core] channel alloc error: ${e instanceof Error?e.message:e}`)}else this.logger.debug?.("[bridge-core] agentFetch/agentGatewayUrl unavailable, skip channel alloc")}async lookupCronJobDescription(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("cron.list",{}),r=s?.jobs;if(!Array.isArray(r))return;for(const t of r){if(!t||"object"!=typeof t)continue;const s=t;if(s.id===e)return"string"==typeof s.description?s.description:void 0}}catch(e){this.logger.debug?.(`[bridge-core] cron.list description lookup failed: ${e instanceof Error?e.message:e}`)}}setCronChannelCache(e,t){if(this.cronChannelCache.size>=AcpGatewayBridgeCore.CRON_CHANNEL_CACHE_MAX){const e=this.cronChannelCache.keys().next().value;void 0!==e&&this.cronChannelCache.delete(e)}this.cronChannelCache.set(e,t)}async refreshCronIsolatedSessionKeys(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.request("cron.list",{}),s=t?.jobs;if(!Array.isArray(s))return;const r=new Set,n=[];for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("string"==typeof t.id&&n.push(t.id),"isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&r.add(s)}this.cronIsolatedSessionKeys=r,this.cronOverrides.reconcile(n)}catch(e){this.logger.debug?.(`[bridge-core] refreshCronIsolatedSessionKeys failed: ${e instanceof Error?e.message:e}`)}}}async function fetchWithRetry(e,t={}){const s=t.maxRetries??3;let r;for(let n=0;n<=s;n++){const i=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(i.ok)return Buffer.from(await i.arrayBuffer());if(429===i.status||i.status>=500){const o=Number(i.headers.get("retry-after"))||0,a=1e3*Math.max(o,2**n);if(r=new Error(`HTTP ${i.status} from ${e}`),n<s){t.logger?.info?.(`[bridge-core] Download ${i.status}, retry ${n+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${i.status}`)}throw r??new Error("Download failed after retries")}const SKILL_ROOT_MARKERS=["SKILL.md","skill.md","SKILL.MD"];async function resolveSkillRoot(e){for(const t of SKILL_ROOT_MARKERS)try{return await stat(join(e,t)),e}catch{}const t=(await readdir(e,{withFileTypes:!0})).filter(e=>e.isDirectory()&&!e.name.startsWith(".")&&"__MACOSX"!==e.name);if(1===t.length){const s=join(e,t[0].name);for(const e of SKILL_ROOT_MARKERS)try{return await stat(join(s,e)),s}catch{}return s}return e}async function extractZipToDir(e,t){const s=new AdmZip(e),r=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(r,{recursive:!0}),s.extractAllTo(r,!0);const e=await resolveSkillRoot(r),n=await readdir(e,{withFileTypes:!0});let i=0;for(const s of n){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const r=join(e,s.name),n=join(t,s.name);await rename(r,n).catch(async()=>{await cp(r,n,{recursive:!0})}),i++}if(0===i)throw new Error("Zip archive contained no extractable files")}finally{await rm(r,{recursive:!0,force:!0}).catch(()=>{})}}const VALID_SLUG_PATTERN=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,NON_ASCII_PATTERN=/[^\x00-\x7F]/;function validateSkillSlug(e){const t=e.trim();if(!t||t.includes("/")||t.includes("\\")||t.includes(".."))throw new Error(`Invalid skill slug: ${e}`);if(NON_ASCII_PATTERN.test(t)||!VALID_SLUG_PATTERN.test(t))throw new Error(`Invalid skill slug: ${e}`);return t}function safeDirName(e){const t=e.trim();return t?t.replaceAll("/","__").replaceAll("\\","__"):t}function resolveSkillInstallDir(e,t){const s=safeDirName(t),r=join(e,s),n=resolve(e),i=resolve(r),o=relative(n,i);if(!o||".."===o||o.startsWith(`..${sep}`)||path.isAbsolute(o))throw new Error("Invalid skill target path");return r}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e,conversationIdFromMessage=e=>{if("string"==typeof e.conversationId&&e.conversationId)return e.conversationId;const n=e.content??{};return"string"==typeof n.conversation_id&&n.conversation_id?n.conversation_id:"string"==typeof n.channel_id&&n.channel_id?n.channel_id:""};export const createImInboundHandler=e=>{const{runtime:n,pluginConfig:s,client:t,localServer:o,promptRunStore:a,sessionMutationCoordinator:i,logger:r,writeObs:c,principalIdOverride:d,promptTimeoutMs:p,dispatch:l=dispatchImInboundPrompt}=e,m=new Map;return{onChatMessage:async e=>{const g=extractChatPrompt(e,{source:"chat_message",localAgentId:o.getLocalAgentId()});if(!g)return;if(!g.channelId||!g.messageId)return void r.warn?.(`[beeos-claw] chat_message missing channelId/messageId (sessionKey=${g.sessionKey}) — drop`);const h=promptRunKey(g.channelId,g.messageId);if(m.has(h))return void r.info?.(`[beeos-claw] duplicate chat_message replay dropped (message_id=${g.messageId}, channelId=${g.channelId})`);if(m.size>=256){const e=m.keys().next().value;void 0!==e&&m.delete(e)}m.set(h,!0);const I=d||o.getDefaultPlatformAgent()?.instanceId||"";if(!I)return void r.warn?.(`[beeos-claw] chat_message dropped (no principalId resolved, sessionKey=${g.sessionKey})`);const _=(e.content??{}).metadata??void 0,u="string"==typeof _?.delivery_principal?_.delivery_principal:"";if(u&&u!==I)return void r.info?.(`[beeos-claw] chat_message skipped (delivery_principal=${u}, selfPrincipalId=${I}, message_id=${g.messageId})`);o.ensureImInboundSession(g.sessionKey,g.channelId,g.messageId);const y=resolveOrigin(_?.protocol);r.debug?.(`[beeos-claw] chat_message dispatch (sessionKey=${g.sessionKey}, channelId=${g.channelId}, origin=${y})`);const f=e=>{c({component:"plugin",domain:"im_dispatch",name:`im.dispatch.${e.type}`,severity:"dispatch_error"===e.type?"warn":"info",payload:e})};try{await l({runtime:n,pluginConfig:s,prompt:{channelId:g.channelId,inboundMessageId:g.messageId,selfPrincipalId:I,dispatchInput:{message:g.message,attachments:g.files},sessionKey:g.sessionKey},client:t,logger:r,promptRunStore:a,sessionMutationCoordinator:i,abortTransport:o,origin:y,promptTimeoutMs:p,onLifecycleEvent:f})}catch(e){r.error?.(`[beeos-claw] chat_message dispatch failed channelId=${g.channelId} in_reply_to=${g.messageId} error_code=runtime_dispatch_failed`)}},onChatCancel:async e=>{const n=e.content??{},s="string"==typeof n.target_message_id?n.target_message_id:"",t="string"==typeof n.reason?n.reason:"unknown";if(!s)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no target_message_id, reason=${t})`);const o=conversationIdFromMessage(e);if(!o)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no conversation identity, target_message_id=${s}, reason=${t})`);const i=`chat_cancel:${t}`,c=await a.abort(promptRunKey(o,s),i);c.ok?r.info?.(`[beeos-claw] chat_cancel
|
|
1
|
+
import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e,conversationIdFromMessage=e=>{if("string"==typeof e.conversationId&&e.conversationId)return e.conversationId;const n=e.content??{};return"string"==typeof n.conversation_id&&n.conversation_id?n.conversation_id:"string"==typeof n.channel_id&&n.channel_id?n.channel_id:""};export const createImInboundHandler=e=>{const{runtime:n,pluginConfig:s,client:t,localServer:o,promptRunStore:a,sessionMutationCoordinator:i,logger:r,writeObs:c,principalIdOverride:d,promptTimeoutMs:p,dispatch:l=dispatchImInboundPrompt}=e,m=new Map;return{onChatMessage:async e=>{const g=extractChatPrompt(e,{source:"chat_message",localAgentId:o.getLocalAgentId()});if(!g)return;if(!g.channelId||!g.messageId)return void r.warn?.(`[beeos-claw] chat_message missing channelId/messageId (sessionKey=${g.sessionKey}) — drop`);const h=promptRunKey(g.channelId,g.messageId);if(m.has(h))return void r.info?.(`[beeos-claw] duplicate chat_message replay dropped (message_id=${g.messageId}, channelId=${g.channelId})`);if(m.size>=256){const e=m.keys().next().value;void 0!==e&&m.delete(e)}m.set(h,!0);const I=d||o.getDefaultPlatformAgent()?.instanceId||"";if(!I)return void r.warn?.(`[beeos-claw] chat_message dropped (no principalId resolved, sessionKey=${g.sessionKey})`);const _=(e.content??{}).metadata??void 0,u="string"==typeof _?.delivery_principal?_.delivery_principal:"";if(u&&u!==I)return void r.info?.(`[beeos-claw] chat_message skipped (delivery_principal=${u}, selfPrincipalId=${I}, message_id=${g.messageId})`);o.ensureImInboundSession(g.sessionKey,g.channelId,g.messageId);const y=resolveOrigin(_?.protocol);r.debug?.(`[beeos-claw] chat_message dispatch (sessionKey=${g.sessionKey}, channelId=${g.channelId}, origin=${y})`);const f=e=>{c({component:"plugin",domain:"im_dispatch",name:`im.dispatch.${e.type}`,severity:"dispatch_error"===e.type?"warn":"info",payload:e})};try{await l({runtime:n,pluginConfig:s,prompt:{channelId:g.channelId,inboundMessageId:g.messageId,selfPrincipalId:I,dispatchInput:{message:g.message,attachments:g.files},sessionKey:g.sessionKey},client:t,logger:r,promptRunStore:a,sessionMutationCoordinator:i,abortTransport:o,origin:y,promptTimeoutMs:p,onLifecycleEvent:f})}catch(e){r.error?.(`[beeos-claw] chat_message dispatch failed channelId=${g.channelId} in_reply_to=${g.messageId} error_code=runtime_dispatch_failed`)}},onChatCancel:async e=>{const n=e.content??{},s="string"==typeof n.target_message_id?n.target_message_id:"",t="string"==typeof n.reason?n.reason:"unknown";if(!s)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no target_message_id, reason=${t})`);const o=conversationIdFromMessage(e);if(!o)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no conversation identity, target_message_id=${s}, reason=${t})`);const i=`chat_cancel:${t}`,c=await a.abort(promptRunKey(o,s),i);c.ok?r.info?.(`[beeos-claw] chat_cancel accepted (target_message_id=${s}, requestId=${c.requestId}, channelId=${c.channelId}, reason=${t})`):r.info?.(`[beeos-claw] chat_cancel no-op (target_message_id=${s}, reason=${t}, why=${c.reason})`)}}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{createImInboundHandler as createImInboundHandlerImpl}from"./im-inbound-handler.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{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
|
+
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("chat_cancel accepted")&&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{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()})});
|
|
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: aborted=false still converges and cancels without restart",async()=>{const s=stubLogger(),n=vi.fn(),o=new SessionMutationCoordinator(new SessionWriteGate,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},s,{onRecoveryRequired:n}),r=vi.fn().mockResolvedValue({aborted:!1,runIds:[]});let i;const c=new Promise(e=>{i=e}),p=makeRuntime(async({abortSignal:e})=>(i(),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}})),l=dispatchImInboundPrompt(baseArgs(p,e,t,a,{logger:s,sessionMutationCoordinator:o,abortTransport:{chatAbort:r}}));await c;const d=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(d.ok).toBe(!0),await expect(l).rejects.toThrow(/chat_cancel:user_request/);const m=e.streams[0];expect(m.finalized?.state).toBe("cancelled"),expect(m.finalized?.stopReason).toBe("user_stop");const u=a.find(e=>"dispatch_error"===e.type);expect(u).toMatchObject({cause:"abort",error:"request_aborted"}),expect(s.lines).toContainEqual(expect.stringContaining("cause=abort dispatchInvoked=true error_code=request_aborted")),expect(r).toHaveBeenCalledWith("agent:main",expect.any(String)),expect(n).not.toHaveBeenCalled(),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()})});
|
|
@@ -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;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
|
|
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_QUIESCENCE_TIMEOUT_MS=3e4,DEFAULT_CANCELLATION_TERMINAL_OBSERVATION_TIMEOUT_MS=3e4;export class SessionMutationCoordinator{gate;quiescence;logger;states=new Map;observationTimeoutMs;cancellationQuiescenceTimeoutMs;cancellationTerminalObservationTimeoutMs;setTimer;clearTimer;onRecoveryRequired;constructor(e,r,o,t={}){this.gate=e,this.quiescence=r,this.logger=o,this.observationTimeoutMs=t.observationTimeoutMs??13e4,this.cancellationQuiescenceTimeoutMs=t.cancellationQuiescenceTimeoutMs??3e4,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 n=this.tryStartInteractive(e,r);return n.ok?(o&&this.bindRun(n.token,o),{ok:!0,ownership:"owner",token:n.token,attached:!1}):{ok:!1,reason:n.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=o.cancelObservationPromise;o.cancelObservationTimer=this.setTimer(()=>{o.terminalKind||o.recovery||this.fence(o,"terminal_not_observed")},this.cancellationTerminalObservationTimeoutMs);const n=Promise.resolve().then(()=>r.chatAbort(e.sessionKey,o.runId)).then(r=>(r.aborted?(o.cancelAcknowledged=!0,this.logger.debug?.(`[session-mutation] remote abort acknowledged sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""} returnedRunIds=${r.runIds.join(",")}`)):this.logger.debug?.(`[session-mutation] remote abort not acknowledged; continuing terminal/quiescence convergence sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""} returnedRunIds=${r.runIds.join(",")}`),o.recovery?{ok:!1,recovery:o.recovery}:{ok:!0,acknowledged:r.aborted,terminalObservation:t})).catch(r=>(this.logger.error?.(`[session-mutation] remote abort transport failed; continuing terminal/quiescence convergence sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""} error=${r instanceof Error?r.message:String(r)}`),o.recovery?{ok:!1,recovery:o.recovery}:{ok:!0,acknowledged:!1,terminalObservation:t}));return o.cancelPromise=n,n}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.recovery)return{ok:!1,recovery:e.recovery};e.phase="awaiting_quiescence";const r="cancelled"===e.terminalKind?this.cancellationQuiescenceTimeoutMs:this.observationTimeoutMs;let o;try{o=await this.quiescence.waitForQuiescence(e.token.sessionKey,r)}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(!o.quiescent){const r="session_unresolved"===o.reason?"session_unresolved":"quiescence_timeout";return{ok:!1,recovery:this.fence(e,r,o)}}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=${o.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(),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
|
+
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("allows terminal quiescence to release before the remote abort observation returns",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,{aborted:!0,runIds:["run-1"]}))}),r=a.converge(o.token,"cancelled");await Promise.resolve(),expect(a.snapshot("agent:main:one")?.cancelAcknowledged).toBe(!1),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),n.resolve(),await expect(s).resolves.toMatchObject({ok:!0,acknowledged:!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}),a=n.tryStartInteractive({sessionKey:"agent:main:fenced"},"message_service");if(!a.ok)throw new Error("expected lease");await expect(n.converge(a.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 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(),a=vi.fn().mockResolvedValue({aborted:!0,runIds:["run-shared-contract"]}),o=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:1})},{},{onRecoveryRequired:n}),s=o.tryStartInteractive({sessionKey:`agent:main:${e}`},e);if(!s.ok)throw new Error("expected lease");o.bindRun(s.token,"run-shared-contract"),await expect(o.requestCancel(s.token,{chatAbort:a})).resolves.toMatchObject({ok:!0,acknowledged:!0});const r=await o.converge(s.token,"cancelled");expect(r.ok).toBe(!0),expect(a).toHaveBeenCalledWith(`agent:main:${e}`,"run-shared-contract"),expect(o.snapshot(`agent:main:${e}`)).toMatchObject({surface:e,phase:"ready_to_release",terminalKind:"cancelled",cancelAcknowledged:!0}),expect(n).not.toHaveBeenCalled()}),it("treats aborted=false as observation and releases after terminal quiescence",async()=>{const e=new SessionWriteGate,t=vi.fn(),n=vi.fn(),a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{debug:n},{onRecoveryRequired:t}),o=a.tryStartInteractive({sessionKey:"agent:main:abort-missed"},"message_service");if(!o.ok)throw new Error("expected lease");a.bindRun(o.token,"run-missed");const s={chatAbort:vi.fn().mockResolvedValue({aborted:!1,runIds:[]})};await expect(a.requestCancel(o.token,s)).resolves.toMatchObject({ok:!0,acknowledged:!1}),expect(a.snapshot("agent:main:abort-missed")).toMatchObject({phase:"cancelling",cancelAcknowledged:!1}),expect(n).toHaveBeenCalledWith(expect.stringContaining("remote abort not acknowledged; continuing terminal/quiescence convergence"));const r=await a.converge(o.token,"cancelled");expect(r.ok).toBe(!0),expect(a.snapshot("agent:main:abort-missed")).toMatchObject({phase:"ready_to_release",terminalKind:"cancelled",cancelAcknowledged:!1}),expect(t).not.toHaveBeenCalled(),r.ok&&a.release(r.permit),expect(e.snapshot("agent:main:abort-missed").active).toBeUndefined(),expect(s.chatAbort).toHaveBeenCalledTimes(1)}),it("uses the shorter quiescence SLA for cancellation",async()=>{const e=new SessionWriteGate,t=vi.fn(async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})),n=new SessionMutationCoordinator(e,{waitForQuiescence:t},{},{observationTimeoutMs:13e4,cancellationQuiescenceTimeoutMs:3e4}),a=n.tryStartInteractive({sessionKey:"agent:main:cancel-sla"},"message_service");if(!a.ok)throw new Error("expected lease");await n.converge(a.token,"cancelled"),expect(t).toHaveBeenCalledWith("agent:main:cancel-sla",3e4)}),it("retains the general quiescence SLA for normal completion",async()=>{const e=new SessionWriteGate,t=vi.fn(async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})),n=new SessionMutationCoordinator(e,{waitForQuiescence:t},{},{observationTimeoutMs:13e4,cancellationQuiescenceTimeoutMs:3e4}),a=n.tryStartInteractive({sessionKey:"agent:main:completion-sla"},"message_service");if(!a.ok)throw new Error("expected lease");await n.converge(a.token,"completed"),expect(t).toHaveBeenCalledWith("agent:main:completion-sla",13e4)}),it("treats remote abort transport errors as observation and still converges",async()=>{const e=new SessionWriteGate,t=vi.fn(),n=vi.fn(),a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:1})},{error:n},{onRecoveryRequired:t}),o=a.tryStartInteractive({sessionKey:"agent:main:abort-transport-error"},"message_service");if(!o.ok)throw new Error("expected lease");await expect(a.requestCancel(o.token,{chatAbort:async()=>{throw new Error("gateway unavailable")}})).resolves.toMatchObject({ok:!0,acknowledged:!1}),expect(n).toHaveBeenCalledWith(expect.stringContaining("remote abort transport failed; continuing terminal/quiescence convergence"));const s=await a.converge(o.token,"cancelled");expect(s.ok).toBe(!0),expect(t).not.toHaveBeenCalled(),s.ok&&a.release(s.permit)}),it("restarts once when aborted=false is not followed by a terminal",async()=>{const e=new SessionWriteGate,t=vi.fn();let n;const a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:3e4,onRecoveryRequired:t,setTimer:e=>(n=e,{unref:()=>{}}),clearTimer:()=>{}}),o=a.tryStartInteractive({sessionKey:"agent:main:false-no-terminal"},"message_service");if(!o.ok)throw new Error("expected lease");const s=await a.requestCancel(o.token,{chatAbort:async()=>({aborted:!1,runIds:[]})});if(!s.ok)throw new Error("expected accepted cancel");expect(s.acknowledged).toBe(!1),n(),n(),await expect(s.terminalObservation).resolves.toMatchObject({ok:!1,recovery:{reason:"terminal_not_observed"}}),expect(t).toHaveBeenCalledOnce()}),it("restarts once when aborted=false reaches terminal but lock quiescence times out",async()=>{const e=new SessionWriteGate,t=vi.fn(),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!1,reason:"timeout",elapsedMs:13e4})},{},{observationTimeoutMs:13e4,onRecoveryRequired:t}),a=n.tryStartInteractive({sessionKey:"agent:main:false-lock-timeout"},"message_service");if(!a.ok)throw new Error("expected lease");await expect(n.requestCancel(a.token,{chatAbort:async()=>({aborted:!1,runIds:[]})})).resolves.toMatchObject({ok:!0,acknowledged:!1}),await expect(n.converge(a.token,"cancelled")).resolves.toMatchObject({ok:!1,recovery:{reason:"quiescence_timeout"}}),expect(t).toHaveBeenCalledOnce()}),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})},{}),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()=>({aborted:!0,runIds:[]})});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()=>({aborted:!0,runIds:[]})});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({aborted:!0,runIds:[]}),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")})});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beeos-claw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.63",
|
|
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>",
|