beeos-claw 0.3.1 → 0.3.3-staging.0
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/README.md +52 -5
- package/dist/acp-gateway/assistant-mq-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-backfill.test.js +1 -0
- package/dist/acp-gateway/gateway-events-heartbeat.test.js +1 -1
- package/dist/acp-gateway/gateway-events.js +1 -1
- package/dist/acp-gateway/mcp-service.js +1 -0
- package/dist/acp-gateway/mcp-service.test.js +1 -0
- package/dist/acp-gateway/pending-user-turn-updates.test.js +1 -0
- package/dist/acp-gateway/session-load-inflight-user.test.js +1 -0
- package/dist/acp-gateway/session-load.test.js +1 -0
- package/dist/acp-gateway/session-state.js +1 -1
- package/dist/acp-gateway/session-state.test.js +1 -1
- package/dist/acp-gateway/transport.js +1 -1
- package/dist/acp-gateway/transport.test.js +1 -1
- package/dist/acp-gateway/types.js +1 -1
- package/dist/acp-server.js +1 -1
- package/dist/auto-reply-tokens.js +1 -0
- package/dist/auto-reply-tokens.test.js +1 -0
- package/dist/config.js +1 -1
- package/dist/index.js +1 -1
- package/dist/message-filter.js +1 -1
- package/dist/message-filter.test.js +1 -1
- package/dist/messaging/chat-envelope.js +1 -0
- package/dist/messaging/chat-envelope.test.js +1 -0
- package/dist/messaging/message-sdk.js +1 -1
- package/dist/openclaw-cli.js +1 -0
- package/dist/status.js +1 -0
- package/dist/surface/cron-resource.js +1 -1
- package/dist/surface/cron-schemas.js +1 -1
- package/dist/surface/index.js +1 -1
- package/dist/utils/mq.js +1 -1
- package/openclaw.plugin.json +0 -4
- package/package.json +2 -2
- package/dist/acp-gateway/session-load-concurrency.test.js +0 -1
package/README.md
CHANGED
|
@@ -61,8 +61,7 @@ Configuration is resolved from three sources in priority order:
|
|
|
61
61
|
| `bridge.service` | `BRIDGE_SERVICE` | `acp` | Service type for Bridge registration. One of: `acp`, `screen`, `desktop`, `shell` |
|
|
62
62
|
| `bridge.mode` | — | — | Bridge operational mode |
|
|
63
63
|
| `bridge.shell.enabled` | `BEEOS_SHELL_ENABLED` | `true` | Enable web-SSH / terminal bridge support |
|
|
64
|
-
| `bridge.promptTimeoutMs` | `BEEOS_PROMPT_TIMEOUT_MS` | `
|
|
65
|
-
| `bridge.historyPendingTimeoutMs` | — | — | Timeout for history replay pending state (ms) |
|
|
64
|
+
| `bridge.promptTimeoutMs` | `BEEOS_PROMPT_TIMEOUT_MS` | `600000` | Timeout for LLM prompt responses (ms) |
|
|
66
65
|
|
|
67
66
|
### Gateway (OpenClaw Gateway Connection)
|
|
68
67
|
|
|
@@ -103,8 +102,6 @@ Configuration is resolved from three sources in priority order:
|
|
|
103
102
|
|---|---|---|---|
|
|
104
103
|
| `streamIdleTimeoutMs` | `BEEOS_STREAM_IDLE_TIMEOUT_MS` | `15000` | Idle timeout for streaming responses (ms) |
|
|
105
104
|
| `streamStaleTimeoutMs` | `BEEOS_STREAM_STALE_TIMEOUT_MS` | `600000` | Stale timeout for abandoned streams (ms) |
|
|
106
|
-
| `loadReplayIdleTimeoutMs` | `BEEOS_LOAD_REPLAY_IDLE_TIMEOUT_MS` | `15000` | Idle timeout for history replay loading (ms) |
|
|
107
|
-
| `cronFlushTimeoutMs` | `BEEOS_CRON_FLUSH_TIMEOUT_MS` | `120000` | Timeout for cron flush operations (ms) |
|
|
108
105
|
| `cronSignalWindowMs` | `BEEOS_CRON_SIGNAL_WINDOW_MS` | `60000` | Window for cron signal batching (ms) |
|
|
109
106
|
|
|
110
107
|
## Full Configuration Example
|
|
@@ -125,7 +122,7 @@ Configuration is resolved from three sources in priority order:
|
|
|
125
122
|
"shell": {
|
|
126
123
|
"enabled": true
|
|
127
124
|
},
|
|
128
|
-
"promptTimeoutMs":
|
|
125
|
+
"promptTimeoutMs": 600000
|
|
129
126
|
},
|
|
130
127
|
"gateway": {
|
|
131
128
|
"url": "ws://127.0.0.1:18789",
|
|
@@ -179,6 +176,56 @@ Logs are written to rolling files in `log.dir` with automatic rotation and sensi
|
|
|
179
176
|
- Node.js >= 18
|
|
180
177
|
- OpenClaw runtime
|
|
181
178
|
|
|
179
|
+
## Release Channels
|
|
180
|
+
|
|
181
|
+
`beeos-claw` publishes to two npm dist-tags so that staging validation never affects production agent pods:
|
|
182
|
+
|
|
183
|
+
| Channel | Git branch | Version shape | npm dist-tag | Docker image tag |
|
|
184
|
+
|---|---|---|---|---|
|
|
185
|
+
| **production** | `main` | `X.Y.Z` (plain SemVer, e.g. `0.2.41`) | `latest` | `openclaw-desktop:latest`, `production-<sha>` |
|
|
186
|
+
| **staging** | `staging` | `X.Y.Z-staging.N` (pre-release, e.g. `0.2.41-staging.0`) | `staging` | `openclaw-desktop:staging-latest`, `staging-<sha>` |
|
|
187
|
+
|
|
188
|
+
The publish workflow [`.github/workflows/publish.yml`](.github/workflows/publish.yml) inspects the `version` in [`package.json`](./package.json):
|
|
189
|
+
|
|
190
|
+
- If the version contains a `-staging.` / `-rc.` / `-beta.` / `-alpha.` suffix → published with `--tag staging`, downstream image built with `channel=staging`.
|
|
191
|
+
- Otherwise → published with `--tag latest`, downstream image built with `channel=production`.
|
|
192
|
+
|
|
193
|
+
### Release to staging
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git checkout staging
|
|
197
|
+
git merge main # or cherry-pick the commits you want to ship
|
|
198
|
+
npm version prerelease --preid=staging --no-git-tag-version
|
|
199
|
+
git commit -am "chore: release $(node -p "require('./package.json').version")"
|
|
200
|
+
git push origin staging
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
CI publishes `beeos-claw@0.X.Y-staging.N` with `dist-tag=staging`, then triggers `beeos-openclaw` to rebuild `openclaw-desktop:staging-latest`. Consumers pulling `beeos-claw@latest` from npm or `openclaw-desktop:latest` from ECR are **not** affected.
|
|
204
|
+
|
|
205
|
+
### Promote to production
|
|
206
|
+
|
|
207
|
+
After staging validation:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
git checkout main
|
|
211
|
+
git merge staging # brings pre-release commits into main
|
|
212
|
+
npm version 0.X.Y --no-git-tag-version # strip the -staging.N suffix
|
|
213
|
+
git commit -am "chore: release $(node -p "require('./package.json').version")"
|
|
214
|
+
git push origin main
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
CI publishes `beeos-claw@0.X.Y` with `dist-tag=latest`, then triggers `beeos-openclaw` to rebuild `openclaw-desktop:latest` (floating production tag, Docker convention) plus an immutable `openclaw-desktop:production-<sha>` for audit/rollback.
|
|
218
|
+
|
|
219
|
+
### Install from a specific channel (for local dev / debugging)
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm install beeos-claw # always resolves to production (dist-tag=latest)
|
|
223
|
+
npm install beeos-claw@staging # latest staging pre-release
|
|
224
|
+
npm install beeos-claw@0.2.41-staging.3 # pin an exact pre-release build
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Pre-release versions never match semver ranges like `^0.2.40`, so production consumers are not silently upgraded to staging builds.
|
|
228
|
+
|
|
182
229
|
## License
|
|
183
230
|
|
|
184
231
|
MIT
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";import{MessageQueue}from"../utils/mq.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"a"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeSession(e,s){return{sessionId:e,gatewaySessionKey:s,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),n=vi.fn(),a=new AcpTransport(n,s);return{core:new AcpGatewayBridgeCore({state:t,transport:a,config:makeConfig(),logger:e,writeObs:s,getGateway:()=>null,instanceId:"test-instance"}),state:t,transport:a,logger:e,writeObs:s,sendBridgeMessage:n}}describe("assistantMq lifecycle — endRun closes mq immediately",()=>{it("cleanupPrompt closes and clears assistantMq synchronously (no late-close timer)",()=>{const{core:e,state:s}=buildCore(),t=makeSession("s1","agent:main:s1");t.assistantMq=new MessageQueue,t.currentRunId="run-1",s.sessions.set(t.sessionId,t),s.sessionsByGatewayKey.set(t.gatewaySessionKey,t);const n=t.assistantMq;expect(n.closed).toBe(!1),e.cleanupPrompt(t),expect(n.closed).toBe(!0),expect(t.assistantMq).toBeUndefined(),expect(t.currentRunId).toBeUndefined(),expect(t.pendingPromptId).toBeUndefined()}),it("handleSessionMessage does NOT touch the mq (mq lifecycle is decoupled from persistence signal)",()=>{const{core:e,state:s}=buildCore(),t=makeSession("s1","agent:main:s1");t.assistantMq=new MessageQueue,t.lastTurnIdByRole={assistant:"turn-1"},s.sessions.set(t.sessionId,t),s.sessionsByGatewayKey.set(t.gatewaySessionKey,t);const n=t.assistantMq;e.handleSessionMessage({sessionKey:t.gatewaySessionKey,messageId:"msg-1",message:{role:"assistant",timestamp:Date.now()}}),expect(n.closed).toBe(!1),expect(t.assistantMq).toBe(n)})});
|
|
@@ -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,stripTransportMetadata,isHeartbeatPromptText,isHeartbeatOnlyText,isCronPromptText}from"../message-filter.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../a2a/mention-resolver.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{loadHistory,groupIntoTurns,extractBeeosFileUrisFromOutput,splitUserReplayBlocks}from"./history-replay.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-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole}from"./gateway-events.js";import{DEFAULT_PROMPT_TIMEOUT_MS,DEFAULT_LOAD_REPLAY_IDLE_TIMEOUT_MS,DEFAULT_LOAD_REPLAY_HARD_TIMEOUT_MS,DEFAULT_ASSISTANTMQ_LATECLOSE_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types";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;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",":a2a:"];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=stripTransportMetadata(e);return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}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 i=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return i&&!i.includes("/")&&SLASH_COMMAND_NAME_RE.test(i)?i.toLowerCase():void 0}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)}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;localSessionHistory;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];_loadSessionLock=new Map;cronIsolatedSessionKeys=new Set;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=[]}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}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.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.transport.setSessionUpdateObserver((e,t,s)=>{const i=s?.turnId;if(!i)return;const n=this.state.sessions.get(e);n&&recordTurnIdForRole(n,t,i)})}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,i=this.state.sessions.has(e.sessionId);if(this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),!i){const t=(new Error).stack?.split("\n")[2]?.trim()??"(unknown)";this.logger.info?.(`[new-chat-stampede] registerSession sid=${e.sessionId.slice(0,12)} title="${(e.title??"").slice(0,24)}" ensureVerbose=${s} stateSize=${this.state.sessions.size} from: ${t}`)}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 i=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,o=n&&!isPlaceholderTitle(n)?n:void 0,r=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:o});this.registerSession(r,{ensureVerbose:!1}),i++}i>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${i} 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"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":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: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.handleSessionCancel(void 0,e.params);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,i=this.state.resolveSessionFromParams(t);if(!i){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}i.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:".",i=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s}),n=this.state.sessions.size;this.registerSession(i,{ensureVerbose:!1}),this.logger.info?.(`[new-chat-stampede] session/new sid=${i.sessionId.slice(0,12)} gwKey=${t.slice(0,40)} stateSize ${n}→${this.state.sessions.size}`),this.transport.sendResult(e.id,{sessionId:i.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:i.agentId,platformAgentId:i.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 i=this.state.sessionsByGatewayKey.get(t);if(!i)return;const n=e.message?.role,o="user"===n?"user":"system"===n?"system":"assistant",r=i.lastTurnIdByRole?.[o];if(!r)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${o} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:r};let c;if("number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(i.sessionId,{sessionUpdate:"message_id_bind",role:o,messageId:s,...l?{timestamp:l}:{}},d),i.lastTurnIdByRole&&(i.lastTurnIdByRole[o]=void 0),"assistant"===o&&i.assistantMq&&!i.assistantMq.closed&&void 0!==i.assistantMqAwaitingRunId){c=i.assistantMqAwaitingRunId;const e=void 0!==i.assistantMqArmedAt?Date.now()-i.assistantMqArmedAt:0;this.clearAssistantMqCloseTimer(i),i.assistantMq.close(),this.logger.info?.(`[bridge-core] assistantMq late-close settled sessionId=${i.sessionId} runId=${c??"n/a"} messageId=${s} awaitedMs=${e}`),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"assistantMq.lateclose.settled",severity:"info",payload:{sessionId:i.sessionId,runId:c,messageId:s,awaitedMs:e}})}"assistant"===o&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,logger:this.logger,writeObs:this.writeObs,isSessionAlive:e=>this.state.sessions.has(e)},{session:i,turnId:r,messageId:s,runId:c})}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const i=s.sessions;if(!Array.isArray(i))return;for(const t of i){const s=t,i=s.sessionKey??s.key??s.id??s.name;if(i&&"string"==typeof i&&deriveSessionId(i,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,n=createSessionObject({gatewayKey:i,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:"),i=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${i}:${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 s=this._loadSessionLock.get(t);if(s)try{await s}catch{}const i=this._doSessionLoad(e,t);this._loadSessionLock.set(t,i);try{await i}finally{this._loadSessionLock.get(t)===i&&this._loadSessionLock.delete(t)}}async _doSessionLoad(e,t){const{session:s}=await this.resolveOrCreateSession(t),i=s.sessionId;this.state.lastActiveSessionId=i,s.chatReplayDedupKeys.clear();let n=[];try{const e=this.getGateway(),t=await loadHistory(e,s.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:s.agentId,sessionId:s.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}});n=groupIntoTurns(t);const o=n.filter(e=>"user"===e.role).length,r=n.filter(e=>"assistant"===e.role).length,a=n.filter(e=>e.toolCalls?.length).length,l=n.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${i} messages=${t.length} turns=${n.length} userTurns=${o} asstTurns=${r} turnsWithToolCalls=${a} totalToolCalls=${l}`),n.length>0&&isPlaceholderTitle(s.title)&&"manual"!==s.titleSource){const e=n.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(e?.content){const t=stripTransportMetadata(e.content).trim().slice(0,64).replace(/\n/g," ").trim();if(t){s.title=t,s.titleSource="truncated";const e=this.getGateway();e?.isConnected&&e.sessionsPatch(s.gatewaySessionKey,{label:t}).catch(()=>{}),this.transport.sendSessionUpdate(i,{sessionUpdate:"title_update",title:t}),this.tryGenerateTitleFromHistory(s,i,n)}}}const d=e=>`history:${s.gatewaySessionKey}:${e}`;for(let e=0;e<n.length;e++){const t=n[e];"user"!==t.role||"internal_system"!==t.provenance?.kind?"user"===t.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(t.content)?this.logger.debug?.(`[bridge-core] SKIP history_turn[${e}] system-injected user message`):"user"===t.role&&isHeartbeatPromptText(t.content)?this.logger.debug?.(`[bridge-core] SKIP history_turn[${e}] heartbeat cron prompt`):"user"===t.role&&isCronPromptText(t.content)?this.logger.debug?.(`[bridge-core] SKIP history_turn[${e}] cron-triggered user message`):"assistant"===t.role&&isHeartbeatOnlyText(t.content)?this.logger.debug?.(`[bridge-core] SKIP history_turn[${e}] heartbeat assistant reply`):"system"!==t.role?(this.logger.debug?.(`[bridge-core] SENDING history_turn[${e}] role=${t.role} contentLen=${t.content.length} content=${JSON.stringify(t.content.slice(0,80))} tcCount=${t.toolCalls?.length??0}`),"user"===t.role?this.replayUserTurnAsChunks(i,t.content,t.metadata,t.timestamp,d(e),t.messageId,t._seq):await this.replayAssistantTurnAsStream(i,t,d(e))):(this.logger.debug?.(`[bridge-core] SENDING system_message[${e}] contentLen=${t.content.length}`),this.transport.sendSessionUpdate(i,{sessionUpdate:"system_message_chunk",content:{type:"text",text:t.content},metadata:t.metadata,...t.timestamp?{timestamp:t.timestamp}:{}},{turnId:d(e),...t.messageId?{messageId:t.messageId}:{},...void 0!==t._seq?{seq:t._seq}:{}})):this.logger.debug?.(`[bridge-core] SKIP history_turn[${e}] internal_system user message`)}}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}const o=new Set;for(const e of n)if(e.toolCalls)for(const t of e.toolCalls)t.id&&o.add(t.id),t.callId&&t.callId!==t.id&&o.add(t.callId);let r="",a="";for(let e=n.length-1;e>=0;e--)if("assistant"===n[e].role){r=n[e].content.slice(-200),a=(n[e].reasoning??"").slice(-200);break}const l=void 0!==s.assistantMq&&!s.assistantMq.closed,d=()=>{this.transport.sendResult(e.id,{sessionId:i}),this.onSessionLoaded?.(i,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})};if(l)return this.replayCurrentAssistantStream(s,o,r,a).catch(e=>{this.logger.warn?.(`[bridge-core] replayCurrentAssistantStream failed: ${e instanceof Error?e.message:e}`)}).finally(()=>d());d()}async replayCurrentAssistantStream(e,t,s="",i=""){if(!e.assistantMq||e.assistantMq.closed)return;const n=this.config.loadReplayIdleTimeoutMs??DEFAULT_LOAD_REPLAY_IDLE_TIMEOUT_MS,o=DEFAULT_LOAD_REPLAY_HARD_TIMEOUT_MS,r=e.assistantMq.peek(0).length;this.writeObs({component:"bridge-core",domain:"lifecycle",name:"session.load.replay_start",severity:"debug",payload:{sessionId:e.sessionId,mqPendingCount:r}});let a=0,l=Date.now();const d={sent:0,skipped:0,cumulativeText:"",cumulativeReasoning:""};for(;e.assistantMq;){const r=e.assistantMq.peek(a);if(r.length>0){l=Date.now();a+=(await this.flushMqItems(e,r,a,t,s,i,d)).total;continue}if(e.assistantMq.closed)break;const c=Date.now()-l;if(c>=o){this.logger.warn?.(`[bridge-core] replayCurrentAssistantStream hard-idle-timeout sessionId=${e.sessionId} idleMs=${c} offset=${a}`),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"session.load.replay_hardtimeout",severity:"warn",payload:{sessionId:e.sessionId,idleMs:c,offset:a}});break}if(c>=n){if(e.promptDone||void 0===e.pendingPromptId){this.logger.info?.(`[bridge-core] replayCurrentAssistantStream soft-idle-stop sessionId=${e.sessionId} idleMs=${c} offset=${a} promptDone=${!!e.promptDone}`),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"session.load.replay_softidle_stop",severity:"info",payload:{sessionId:e.sessionId,idleMs:c,offset:a,promptDone:!!e.promptDone}});break}this.logger.debug?.(`[bridge-core] replayCurrentAssistantStream soft-idle-continue sessionId=${e.sessionId} idleMs=${c} offset=${a}`),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"session.load.replay_softidle_continue",severity:"debug",payload:{sessionId:e.sessionId,idleMs:c,offset:a}})}await new Promise(e=>{const t=setTimeout(e,200);"object"==typeof t&&"unref"in t&&t.unref()})}d.skipped>0&&this.logger.debug?.(`[bridge-core] replayCurrentAssistantStream skipped ${d.skipped} duplicate tool events (already in history)`),(d.sent>0||d.skipped>0)&&this.writeObs({component:"bridge-core",domain:"lifecycle",name:"session.load.replay_complete",severity:"debug",payload:{sessionId:e.sessionId,replayed:d.sent,skipped:d.skipped}})}async flushMqItems(e,t,s,i,n,o,r){const a=r??{sent:0,skipped:0,cumulativeText:"",cumulativeReasoning:""};let l=0;for(const s of t)if(l++,!i?.size||"tool_call"!==s.sessionUpdate&&"tool_call_update"!==s.sessionUpdate||!i.has(s.toolCallId)){if(n.length>0&&"agent_message_chunk"===s.sessionUpdate){const e="text"===s.content.type?s.content.text:void 0;if("string"==typeof e&&(a.cumulativeText+=e,a.cumulativeText.length<=n.length)){a.skipped++;continue}}if(o.length>0&&"agent_thought_chunk"===s.sessionUpdate){const e="text"===s.content.type?s.content.text:void 0;if("string"==typeof e&&(a.cumulativeReasoning+=e,a.cumulativeReasoning.length<=o.length)){a.skipped++;continue}}this.transport.sendSessionUpdate(e.sessionId,s),a.sent++}else a.skipped++;return{total:l}}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,i=s.sessionKey??s.key??s.id??s.name;if(!i||"string"!=typeof i)continue;if(isBackgroundSessionKey(i))continue;if(this.cronIsolatedSessionKeys.has(i))continue;const n=deriveSessionId(i,this.instanceId),o=t.find(e=>e.sessionId===i||e.sessionId===n||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===i),r=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=r&&!isPlaceholderTitle(r)?r:void 0,l=s.model,d=s.modelProvider;if(o){if(a&&isPlaceholderTitle(o.title)){o.title=a;const e=this.state.sessions.get(o.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(o.updatedAt=e),l&&(o.model=l),d&&(o.modelProvider=d)}else{const e=createSessionObject({gatewayKey:i,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(i),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));try{const e=t.map(e=>{const t=this.state.sessions.get(e.sessionId);return{sid:e.sessionId.slice(0,12),title:(e.title||"").slice(0,24),titleSource:t?.titleSource??"(none)",hasActivity:!!t?.lastStreamActivityTs,createdAt:t?.createdAt,updatedAt:e.updatedAt,gwKey:t?.gatewaySessionKey?.slice(0,40)}});this.logger.info?.(`[new-chat-stampede] session/list n=${t.length} total_state=${this.state.sessions.size} entries=${JSON.stringify(e)}`)}catch{}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 i=s.slice(0,64),n=this.state.sessions.get(t);if(!n)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);n.title=i,n.titleSource="manual";const o=n.gatewaySessionKey,r=this.getGateway();r?.isConnected&&r.sessionsPatch(o,{label:i}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:i})}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,i){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 o=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),r=process.env.AROUTER_API_KEY||"";if(!o||!r)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(i)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${i.length}`),fetch(`${o}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},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 i=await s.json(),n=i.choices?.[0]?.message?.content?.trim();if(!n)return;const o=n.replace(/^```json\s*/,"").replace(/```$/,"").trim();let r;try{r=JSON.parse(o).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${o}`)}if(!r)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="${r}"`),e.title=r,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:r})}).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 i=this.getGateway();if(!i?.isConnected)return;let n=s;if(!n)try{const t=await loadHistory(i,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 o=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=stripTransportMetadata(e.content).trim();return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!o)return;const r=n.indexOf(o),a=n.slice(r+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=stripTransportMetadata(o.content).trim(),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 i=s.gatewaySessionKey;this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${i} gwConnected=${!!this.getGateway()?.isConnected}`);let n=!1;const o=this.getGateway();if(o?.isConnected&&i)try{await o.sessionsDelete(i),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${i}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${i}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await o.sessionsReset(i),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${i}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${i}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else i&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${i}`);void 0!==s.pendingPromptId&&this.failPrompt(s,Errors.SessionDeleted.rpcCode,Errors.SessionDeleted.message),this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),this.clearAssistantMqCloseTimer(s),s.assistantMq?.close(),s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const r=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),r.add(e));for(const[e,t]of this.state.toolCallIdMap)r.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(i),this.transport.resetSessionUpdateCount(t),n&&this.onSessionDeleted?.(i),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${i} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}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 i=this.getGateway(),n=i?.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}`),n&&(s.currentRunId||s.gatewayRequestId)&&i.agentCancel(s.gatewaySessionKey,{runId:s.currentRunId,requestId:s.gatewayRequestId}),this.endRun(s,{kind:"complete",stopReason:"cancelled"})),n&&s.gatewaySessionKey)try{await i.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,i=t?this.state.sessions.get(t):void 0,n=this.getGateway();if(i&&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(i.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,i=t?this.state.sessions.get(t):void 0,n=this.getGateway();let o=s??null,r=null;if(i&&n?.isConnected&&s)try{const e=await n.sessionsPatch(i.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(o=t.model),t?.modelProvider&&(r=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:o,modelProvider:r})}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=await this.fetchSimplifiedSkills();this.transport.sendResult(e.id,{skills:t})}async fetchSimplifiedSkills(){const e=this.getGateway();if(!e?.isConnected)return[];try{const t=await e.skillsStatus();if(!t?.skills)return[];const s=t.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})),i=this.getSkillsDir();for(const e of s){const t=e.skillKey??e.name;try{const s=await readFile(join(i,t,".beeos-meta.json"),"utf8"),n=JSON.parse(s);n?.iconUrl&&(e.iconUrl=n.iconUrl)}catch{}}return s}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(){const e=await this.fetchSimplifiedSkills();this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:e}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:e.length}})}getSkillsDir(){const e=process.env.OPENCLAW_HOME||join(homedir(),".openclaw");return join(e,"skills")}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,i=t?.files;let n;try{n=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!i?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(this.getSkillsDir(),n);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(i?.length){const e=resolve(o);for(const t of i){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 i=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!i.ok)throw new Error(`Failed to download ${t.path}: ${i.status}`);const n=Buffer.from(await i.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 "${n}" via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(n,o),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${n}": ${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;let s;try{s=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const i=resolveSkillInstallDir(this.getSkillsDir(),s),n=resolveSkillInstallDir(join(homedir(),".agents","skills"),s);try{let t=!1;try{const e=await lstat(i);if(e.isSymbolicLink()){const e=await readlink(i);await rm(i,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(n);(e.isDirectory()||e.isSymbolicLink())&&(await rm(n,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${s}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${s}"`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${s}": ${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,i=t?.enabled;if(!s||"boolean"!=typeof i)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const n=this.getGateway();if(n?.isConnected)try{const t=await n.skillsUpdate(s,i);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${i}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:i}),await this.pushSkillsList()}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:i,created:n}=await this.resolveOrCreateSession(t);n&&this.onSessionLoaded?.(i.sessionId,i.gatewaySessionKey,i.platformAgentId);const o=i.sessionId;this.state.lastActiveSessionId=o,this.onPromptStart?.();const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);await this.ensureVerboseFull(i);let a=extractPromptBlocks(sanitizePromptPayload(s));if(!i.title||isPlaceholderTitle(i.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=${o} existing="${i.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();i.title=e,i.titleSource="truncated",r?.isConnected&&e&&r.sessionsPatch(i.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(o,{sessionUpdate:"title_update",title:e})}}}const l=buildResolutionPlan(a);if(l.refs.length>0)try{const e=await resolveResources(l,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let d;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{d=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const c=toGatewayPrompt(a,{beeosFileResolutionsByUri:d});if("error"in c)return void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:c.error});const{message:m,attachments:g}=c,h=resolveStandaloneSlashCommandName(a,m)??resolveSlashCmd(a,m),p="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(i.sessionId,a,resolveTurnId(i)),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${h}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(i)}),void this.transport.sendResult(e.id,{stopReason:"end_turn"})}const u="chat.send";let y=p?m:withUserMessagePrefix(m);const f=this.config.canvasEnabled&&!p&&!1!==i.canvasActive&&!i.channelType;if(f){const e=i.promptsSinceCanvasInjection??0;!i.canvasPromptInjected||e>=10?(y=`${CANVAS_TERM_MAPPING}\n\n${y}`,i.canvasPromptInjected=!0,i.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):i.promptsSinceCanvasInjection=e+1}if(f){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(i.gatewaySessionKey);y=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${y}`}const t=i.referencedSurfaceId;t&&(y=`${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${y}`,i.referencedSurfaceId=void 0)}if(!p){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){i.activeMentions=t;y=`${buildMentionInstruction(t)}\n${y}`,this.logger.debug?.(`[bridge-core] A2A mention instruction injected for ${t.length} agent(s)`)}else i.activeMentions=void 0}p&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${o} command=/${h}`),i.promptDone||void 0===i.pendingPromptId&&void 0===i.currentRunId||(this.logger.warn?.(`[bridge-core] handleSessionPrompt: force-ending stale run sid=${o} oldRunId=${i.currentRunId} oldPromptId=${i.pendingPromptId}`),(i.currentRunId||i.gatewayRequestId)&&r.agentCancel(i.gatewaySessionKey,{runId:i.currentRunId,requestId:i.gatewayRequestId}),this.endRun(i,{kind:"complete",stopReason:"cancelled"})),this.state.resetForNewRun(i,o,this.transport),i.pendingPromptId=e.id,i.gatewayMethod=u;const I=resolveTurnId(i);this.emitPromptAsUserSessionUpdates(o,a,I),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},I?{turnId:I}:void 0),this.schedulePromptTimeout(i);const S=g.length>0?g.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,w=`req_${randomUUID().replace(/-/g,"")}`,b={sessionKey:i.gatewaySessionKey,message:y,deliver:!1,idempotencyKey:randomUUID()};S?.length&&(b.attachments=S);if(!r.sendFrame({type:"req",id:w,method:u,params:b}))return this.state.clearPromptTimeout(i),i.pendingPromptId=void 0,this.clearAssistantMqCloseTimer(i),i.assistantMq?.close(),void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});i.gatewayRequestId=w,this.state.promptsByGatewayRequestId.set(w,i)}replayUserTurnAsChunks(e,t,s,i,n,o,r){const a=splitUserReplayBlocks(t),l=n||o||void 0!==r?{...n?{turnId:n}:{},...o?{messageId:o}:{},...void 0!==r?{seq:r}:{}}:void 0;for(const t of a){let n;if("text"===t.type){if(!t.text)continue;n={type:"text",text:t.text}}else n="resource_link"===t.type?{type:"resource_link",uri:t.uri??"",...t.fileName?{name:t.fileName}:{},...t.mimeType?{mimeType:t.mimeType}:{}}:{type:t.type,...t.mimeType?{mimeType:t.mimeType}:{},...t.fileName?{fileName:t.fileName}:{}};this.transport.sendSessionUpdate(e,{sessionUpdate:"user_message_chunk",content:n,...s?{metadata:s}:{},...i?{timestamp:i}:{}},l)}}async replayAssistantTurnAsStream(e,t,s){const i=(e,t)=>{if(s||e||void 0!==t)return{...s?{turnId:s}:{},...e?{messageId:e}:{},...void 0!==t?{seq:t}:{}}},n=i(t.messageId,t._seq);if(!t.sourceMessages?.length){const s={sessionUpdate:"history_turn",role:t.role,content:t.content,reasoning:t.reasoning,toolCalls:t.toolCalls,...t.timestamp?{timestamp:t.timestamp}:{}};return t.metadata&&(s.metadata=t.metadata),void this.transport.sendSessionUpdate(e,s,n)}for(const s of t.sourceMessages){const n=s.updateType??"agent_message_chunk",o=i(s.messageId,s._seq);switch(n){case"agent_message_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{},...s.metadata?{metadata:s.metadata}:{}},o);break;case"agent_thought_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{}},o);break;case"tool_call":{const i=s.toolCallId??"",n=t.toolCalls?.find(e=>e.id===i),r=s.toolName??"tool",a=s.content??"";let l={};if(a)try{l=JSON.parse(a)}catch{}this.transport.sendSessionUpdate(e,buildToolCallPayload({toolCallId:i,toolName:r,args:l,kind:n?.kind,status:n?.status??"in_progress",timestamp:s.timestamp}),o);break}case"tool_call_update":{const i=s.toolCallId??"",n=t.toolCalls?.find(e=>e.id===i),r=s.toolName??n?.name??"tool",a=s.content??n?.output??"";let l=await readEnrichmentCache(i).catch(()=>{});if(!l){const e=extractBeeosFileUrisFromOutput(a);e.length>0&&(l=e.map(e=>({uri:e,name:"file"})))}if(!l&&"beeos_upload_file"===r){const e=await readUploadToolResult(i).catch(()=>{});if(e){const t=buildToolResultSessionContentBlocks(r,e).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}:{}}));t.length>0&&(l=t)}}this.transport.sendSessionUpdate(e,buildToolCallUpdatePayload({toolCallId:i,toolName:r,outputText:a,kind:n?.kind,status:n?.status??"completed",resourceLinks:l,timestamp:s.timestamp}),o);break}}}}emitPromptAsUserSessionUpdates(e,t,s){const i=(new Date).toISOString(),n=s?{turnId:s}:void 0;for(const s of t){const t=this.toUserSessionUpdateContent(s);t&&this.transport.sendSessionUpdate(e,{sessionUpdate:"user_message_chunk",content:t,timestamp:i},n)}}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,i=t.mimeType??t.mime_type??e.mimeType??e.mime_type,n=t.text??e.text,o=t.data??e.data,r=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!n&&!o)return;return{type:"resource",resource:{...s?{uri:s}:{},...i?{mimeType:i}:{},...n?{text:n}:{},...o?{data:o}:{},...r?{fileName:r}:{}}}}}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 i=this.state.sessions.get(s);if(!i)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const n=this.getGateway(),o=n?.isConnected,r=!(!i.currentRunId&&!i.gatewayRequestId);if(o&&r){n.agentCancel(i.gatewaySessionKey,{runId:i.currentRunId,requestId:i.gatewayRequestId})}this.endRun(i,{kind:"complete",stopReason:"cancelled"})||this.transport.sendSessionUpdate(i.sessionId,{sessionUpdate:"stream_complete",stopReason:"cancelled"},{turnId:resolveTurnId(i)}),void 0!==e&&this.transport.sendResult(e,{})}endRun(e,t){if(e.promptDone)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)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;const s=resolveTurnId(e),i=s?{turnId:s}:void 0;e.promptDone=!0;const n=e.currentRunId;if("complete"===t.kind){if(n&&this.onRunComplete){const t=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(n,t)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:t.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:t.stopReason},i),this.tryGenerateTitleFromHistory(e,e.sessionId)}else n&&this.onRunFailed&&this.onRunFailed(n,t.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,t.code,t.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:t.message},i);return this.cleanupPrompt(e),this.onRunEnded?.(e,t),!0}schedulePromptTimeout(e){this.state.clearPromptTimeout(e);const t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{void 0!==e.pendingPromptId&&(this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),this.failPrompt(e,Errors.PromptTimeout.rpcCode,`Prompt timed out after ${t}ms`))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.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);const s=e.currentRunId;e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,this.armAssistantMqLateClose(e,s)}armAssistantMqLateClose(e,t){if(!e.assistantMq||e.assistantMq.closed)return;this.clearAssistantMqCloseTimer(e),e.assistantMqAwaitingRunId=t,e.assistantMqArmedAt=Date.now();const s=DEFAULT_ASSISTANTMQ_LATECLOSE_TIMEOUT_MS;this.writeObs({component:"bridge-core",domain:"lifecycle",name:"assistantMq.lateclose.armed",severity:"debug",payload:{sessionId:e.sessionId,runId:t,timeoutMs:s}}),e.assistantMqCloseTimer=setTimeout(()=>{e.assistantMq&&!e.assistantMq.closed&&(this.logger.warn?.(`[bridge-core] assistantMq late-close timeout sessionId=${e.sessionId} runId=${t??"n/a"} timeoutMs=${s}`),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"assistantMq.lateclose.timeout",severity:"warn",payload:{sessionId:e.sessionId,runId:t,timeoutMs:s}}),e.assistantMq.close()),e.assistantMqCloseTimer=void 0,e.assistantMqAwaitingRunId=void 0,e.assistantMqArmedAt=void 0},s),"object"==typeof e.assistantMqCloseTimer&&"unref"in e.assistantMqCloseTimer&&e.assistantMqCloseTimer.unref()}clearAssistantMqCloseTimer(e){e.assistantMqCloseTimer&&(clearTimeout(e.assistantMqCloseTimer),e.assistantMqCloseTimer=void 0),e.assistantMqAwaitingRunId=void 0,e.assistantMqArmedAt=void 0}completePrompt(e,t){this.endRun(e,{kind:"complete",stopReason:t})}failPrompt(e,t,s){this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())this.failPrompt(e,Errors.GatewayDisconnected.rpcCode,Errors.GatewayDisconnected.message)}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 i=e.error,n=i?.message??"gateway returned error";return this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${n}`),void this.failPrompt(s,Errors.SendFailed.rpcCode,n)}const i=e.payload,n=i?.runId;n?(s.currentRunId=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)this.completePrompt(e,"end_turn");else if("aborted"===s||"cancelled"===s||"cancel"===s)this.completePrompt(e,"cancelled");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";this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}pushToolResultContent(e,t,s,i){if(0===s.length)return;const n=i?this.state.promptsByRunId.get(i):void 0;if(!n)return void this.logger.warn?.(`[bridge-core] pushToolResultContent dropped — no session for runId=${i??"(none)"}`);const o=n.sessionId,r=(new Date).toISOString(),a={turnId:resolveTurnId(n)};for(const e of s){const t=narrowContentBlock(e);if(!t)continue;const s={sessionUpdate:"agent_message_chunk",content:t,timestamp:r};this.transport.sendSessionUpdate(o,s,a),n.assistantMq?.push(s)}this.writeObs({component:"bridge-core",domain:"tool",name:"tool_result.direct_push",severity:"info",payload:{toolCallId:e,toolName:t,blockCount:s.length,sessionId:o}})}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/",".");try{const i=await t.request(s,e.params??{});this.transport.sendResult(e.id,i??{}),"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const i=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${i}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:i})}}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 i=new Set;for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&i.add(s)}this.cronIsolatedSessionKeys=i}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 i;for(let n=0;n<=s;n++){const o=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(o.ok)return Buffer.from(await o.arrayBuffer());if(429===o.status||o.status>=500){const r=Number(o.headers.get("retry-after"))||0,a=1e3*Math.max(r,2**n);if(i=new Error(`HTTP ${o.status} from ${e}`),n<s){t.logger?.info?.(`[bridge-core] Download ${o.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: ${o.status}`)}throw i??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),i=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(i,{recursive:!0}),s.extractAllTo(i,!0);const e=await resolveSkillRoot(i),n=await readdir(e,{withFileTypes:!0});let o=0;for(const s of n){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const i=join(e,s.name),n=join(t,s.name);await rename(i,n).catch(async()=>{await cp(i,n,{recursive:!0})}),o++}if(0===o)throw new Error("Zip archive contained no extractable files")}finally{await rm(i,{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),i=join(e,s),n=resolve(e),o=resolve(i),r=relative(n,o);if(!r||".."===r||r.startsWith(`..${sep}`)||path.isAbsolute(r))throw new Error("Invalid skill target path");return i}
|
|
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,stripTransportMetadata,isHeartbeatPromptText,isCronPromptText,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../a2a/mention-resolver.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{loadHistory,groupIntoTurns,extractBeeosFileUrisFromOutput,splitUserReplayBlocks}from"./history-replay.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-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole}from"./gateway-events.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";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;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",":a2a:"];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=stripTransportMetadata(e);return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}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 n=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return n&&!n.includes("/")&&SLASH_COMMAND_NAME_RE.test(n)?n.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`;return e?`${e}\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=.+? -->/,"").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)}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];cronIsolatedSessionKeys=new Set;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=[]}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}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.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const n=s?.turnId;if(!n)return;const i=this.state.sessions.get(e);i&&recordTurnIdForRole(i,t,n)})}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 n=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 i=t.label||t.derivedTitle||t.title||void 0,o=i&&!isPlaceholderTitle(i)?i:void 0,r=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:o});this.registerSession(r,{ensureVerbose:!1}),n++}n>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${n} 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"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":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:"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.handleSessionCancel(void 0,e.params);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,n=this.state.resolveSessionFromParams(t);if(!n){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}n.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:".",n=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(n,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:n.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:n.agentId,platformAgentId:n.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 n=this.state.sessionsByGatewayKey.get(t);if(!n)return;const i=e.message?.role,o="user"===i?"user":"system"===i?"system":"assistant",r=n.lastTurnIdByRole?.[o];if(!r)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${o} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:r};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"message_id_bind",role:o,messageId:s,...l?{timestamp:l}:{}},d),n.lastTurnIdByRole&&(n.lastTurnIdByRole[o]=void 0),"assistant"===o&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,logger:this.logger,writeObs:this.writeObs,isSessionAlive:e=>this.state.sessions.has(e)},{session:n,turnId:r,messageId:s,runId:void 0})}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const n=s.sessions;if(!Array.isArray(n))return;for(const t of n){const s=t,n=s.sessionKey??s.key??s.id??s.name;if(n&&"string"==typeof n&&deriveSessionId(n,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,i=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(i),i}}}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:"),n=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${n}:${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),n=s.sessionId;this.state.lastActiveSessionId=n,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:n}),this.onSessionLoaded?.(n,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 n=this.getGateway(),i=e.assistantMq&&!e.assistantMq.closed,o=i?null:n,r=await loadHistory(o,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(r),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,h=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${r.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${h}`),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=stripTransportMetadata(e.content).trim();return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const n=stripTransportMetadata(t.content).trim().slice(0,64).replace(/\n/g," ").trim();if(n){e.title=n,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:n}),this.tryGenerateTitleFromHistory(e,s,a)}}}const g=t=>`history:${e.gatewaySessionKey}:${t}`;let p=0,m=-1;if(i)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){m=e;break}for(let n=0;n<a.length;n++){const o=a[n];if(i&&n===m)this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] assistant — mq active, deferring to emitCurrentStreamSnapshot`);else if("user"!==o.role||"internal_system"!==o.provenance?.kind)if("user"===o.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(o.content))this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] system-injected user message`);else if("user"===o.role&&isHeartbeatPromptText(o.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);else if("user"===o.role&&isCronPromptText(o.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] cron-triggered user message sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);else{if("assistant"===o.role){const t=classifyHeartbeatFilter(o.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);continue}}if("system"!==o.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${n}] role=${o.role} contentLen=${o.content.length} content=${JSON.stringify(o.content.slice(0,80))} tcCount=${o.toolCalls?.length??0}`),"user"===o.role)this.replayUserTurnAsChunks(s,o.content,o.metadata,o.timestamp,g(n),o._seq);else{if(await this.replayAssistantTurnAsStream(s,o,g(n)),o.toolCalls)for(const e of o.toolCalls)e.id&&t.add(e.id);this.transport.sendSessionUpdate(s,{sessionUpdate:"stream_complete",stopReason:"end_turn"},{turnId:g(n)}),p+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${n}] contentLen=${o.content.length}`),this.transport.sendSessionUpdate(s,{sessionUpdate:"system_message_chunk",content:{type:"text",text:o.content},metadata:o.metadata,...o.timestamp?{timestamp:o.timestamp}:{}},{turnId:g(n),...void 0!==o._seq?{seq:o._seq}:{}})}else this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] internal_system user message`)}this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${p} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const n=e.assistantMq;if(n&&!n.closed){const s=n.peek(0);let i=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const n=s[e],o=n.sessionUpdate,r=n.toolCallId;if("tool_call"!==o&&"tool_call_update"!==o||!r||!t.has(r)){if("agent_message_chunk"===o){if(""===(n.content?.text??"").trim()&&e<s.length-1){const n=s[e+1];if(("tool_call"===n.sessionUpdate||"tool_call_update"===n.sessionUpdate)&&n.toolCallId&&t.has(n.toolCallId)){i=e+1;continue}}break}break}i=e+1}const o=resolveTurnId(e),r=o?{turnId:o}:void 0;for(let t=i;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],r)}}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,n=s.sessionKey??s.key??s.id??s.name;if(!n||"string"!=typeof n)continue;if(isBackgroundSessionKey(n))continue;if(this.cronIsolatedSessionKeys.has(n))continue;const i=deriveSessionId(n,this.instanceId),o=t.find(e=>e.sessionId===n||e.sessionId===i||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===n),r=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=r&&!isPlaceholderTitle(r)?r:void 0,l=s.model,d=s.modelProvider;if(o){if(a&&isPlaceholderTitle(o.title)){o.title=a;const e=this.state.sessions.get(o.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(o.updatedAt=e),l&&(o.model=l),d&&(o.modelProvider=d)}else{const e=createSessionObject({gatewayKey:n,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(n),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 n=s.slice(0,64),i=this.state.sessions.get(t);if(!i)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);i.title=n,i.titleSource="manual";const o=i.gatewaySessionKey,r=this.getGateway();r?.isConnected&&r.sessionsPatch(o,{label:n}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:n})}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,n){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const i=this.matchTitleTemplate(s);if(i){e.title=i,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:i}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:i})}const o=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),r=process.env.AROUTER_API_KEY||"";if(!o||!r)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(n)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${n.length}`),fetch(`${o}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},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 n=await s.json(),i=n.choices?.[0]?.message?.content?.trim();if(!i)return;const o=i.replace(/^```json\s*/,"").replace(/```$/,"").trim();let r;try{r=JSON.parse(o).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${o}`)}if(!r)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="${r}"`),e.title=r,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:r})}).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 n=this.getGateway();if(!n?.isConnected)return;let i=s;if(!i)try{const t=await loadHistory(n,e.gatewaySessionKey);i=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const o=i.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=stripTransportMetadata(e.content).trim();return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!o)return;const r=i.indexOf(o),a=i.slice(r+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=stripTransportMetadata(o.content).trim(),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 n=s.gatewaySessionKey;this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${n} gwConnected=${!!this.getGateway()?.isConnected}`);let i=!1;const o=this.getGateway();if(o?.isConnected&&n)try{await o.sessionsDelete(n),i=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${n}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${n}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await o.sessionsReset(n),i=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${n}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${n}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else n&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${n}`);void 0!==s.pendingPromptId&&this.failPrompt(s,Errors.SessionDeleted.rpcCode,Errors.SessionDeleted.message),this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const r=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),r.add(e));for(const[e,t]of this.state.toolCallIdMap)r.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(n),this.transport.resetSessionUpdateCount(t),i&&this.onSessionDeleted?.(n),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${n} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}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 n=this.getGateway(),i=n?.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}`),i&&(s.currentRunId||s.gatewayRequestId)&&n.agentCancel(s.gatewaySessionKey,{runId:s.currentRunId,requestId:s.gatewayRequestId}),this.endRun(s,{kind:"complete",stopReason:"cancelled"})),i&&s.gatewaySessionKey)try{await n.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=${i}, 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,n=t?this.state.sessions.get(t):void 0,i=this.getGateway();if(n&&i?.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 i.sessionsPatch(n.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,n=t?this.state.sessions.get(t):void 0,i=this.getGateway();let o=s??null,r=null;if(n&&i?.isConnected&&s)try{const e=await i.sessionsPatch(n.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(o=t.model),t?.modelProvider&&(r=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:o,modelProvider:r})}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=await this.fetchSimplifiedSkills();this.transport.sendResult(e.id,{skills:t})}async fetchSimplifiedSkills(){const e=this.getGateway();if(!e?.isConnected)return[];try{const t=await e.skillsStatus();if(!t?.skills)return[];const s=t.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=this.getSkillsDir();for(const e of s){const t=e.skillKey??e.name;try{const s=await readFile(join(n,t,".beeos-meta.json"),"utf8"),i=JSON.parse(s);i?.iconUrl&&(e.iconUrl=i.iconUrl)}catch{}}return s}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(){const e=await this.fetchSimplifiedSkills();this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:e}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:e.length}})}getSkillsDir(){const e=process.env.OPENCLAW_HOME||join(homedir(),".openclaw");return join(e,"skills")}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,n=t?.files;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(!n?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(this.getSkillsDir(),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(n?.length){const e=resolve(o);for(const t of n){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 n=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!n.ok)throw new Error(`Failed to download ${t.path}: ${n.status}`);const i=Buffer.from(await n.arrayBuffer());await writeFile(s,i)}}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}" via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList()}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;let s;try{s=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const n=resolveSkillInstallDir(this.getSkillsDir(),s),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),s);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 "${s}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${s}"`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${s}": ${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,n=t?.enabled;if(!s||"boolean"!=typeof n)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,n);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${n}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:n}),await this.pushSkillsList()}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:n,created:i}=await this.resolveOrCreateSession(t);i&&this.onSessionLoaded?.(n.sessionId,n.gatewaySessionKey,n.platformAgentId);const o=n.sessionId;this.state.lastActiveSessionId=o,this.onPromptStart?.();const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);await this.ensureVerboseFull(n);let a=extractPromptBlocks(sanitizePromptPayload(s));if(!n.title||isPlaceholderTitle(n.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=${o} existing="${n.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();n.title=e,n.titleSource="truncated",r?.isConnected&&e&&r.sessionsPatch(n.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(o,{sessionUpdate:"title_update",title:e})}}}const l=buildResolutionPlan(a);if(l.refs.length>0)try{const e=await resolveResources(l,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let d;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{d=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const c=toGatewayPrompt(a,{beeosFileResolutionsByUri:d});if("error"in c)return void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:c.error});const{message:h,attachments:g}=c,p=resolveStandaloneSlashCommandName(a,h)??resolveSlashCmd(a,h),m="string"==typeof p;if(p&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(p)}catch(e){return t.logger.warn?.(`[bridge-core] /${p} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(n,a,resolveTurnId(n)),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${p}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(n)}),void this.transport.sendResult(e.id,{stopReason:"end_turn"})}const u="chat.send";let y=m?h:withUserMessagePrefix(h);const f=this.config.canvasEnabled&&!m&&!1!==n.canvasActive&&!n.channelType;if(f){const e=n.promptsSinceCanvasInjection??0;!n.canvasPromptInjected||e>=10?(y=`${CANVAS_TERM_MAPPING}\n\n${y}`,n.canvasPromptInjected=!0,n.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):n.promptsSinceCanvasInjection=e+1}if(f){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(n.gatewaySessionKey);y=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${y}`}const t=n.referencedSurfaceId;t&&(y=`${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${y}`,n.referencedSurfaceId=void 0)}if(!m){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){n.activeMentions=t;y=`${buildMentionInstruction(t)}\n${y}`,this.logger.debug?.(`[bridge-core] A2A mention instruction injected for ${t.length} agent(s)`)}else n.activeMentions=void 0}m&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${o} command=/${p}`),n.promptDone||void 0===n.pendingPromptId&&void 0===n.currentRunId||(this.logger.warn?.(`[bridge-core] handleSessionPrompt: force-ending stale run sid=${o} oldRunId=${n.currentRunId} oldPromptId=${n.pendingPromptId}`),(n.currentRunId||n.gatewayRequestId)&&r.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId}),this.endRun(n,{kind:"complete",stopReason:"cancelled"})),this.state.resetForNewRun(n,o,this.transport),n.pendingPromptId=e.id,n.gatewayMethod=u;const S=resolveTurnId(n);this.emitPromptAsUserSessionUpdates(n,a,S),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},S?{turnId:S}:void 0),this.schedulePromptTimeout(n);const w=g.length>0?g.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,I=`req_${randomUUID().replace(/-/g,"")}`,v={sessionKey:n.gatewaySessionKey,message:y,deliver:!1,idempotencyKey:randomUUID()};w?.length&&(v.attachments=w);if(!r.sendFrame({type:"req",id:I,method:u,params:v}))return this.state.clearPromptTimeout(n),n.pendingPromptId=void 0,n.assistantMq?.close(),n.assistantMq=void 0,n.pendingUserTurnUpdates=void 0,void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});n.gatewayRequestId=I,this.state.promptsByGatewayRequestId.set(I,n)}replayUserTurnAsChunks(e,t,s,n,i,o){const r=splitUserReplayBlocks(t),a=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const t of r){let i;if("text"===t.type){if(!t.text)continue;i={type:"text",text:t.text}}else i="resource_link"===t.type?{type:"resource_link",uri:t.uri??"",...t.fileName?{name:t.fileName}:{},...t.mimeType?{mimeType:t.mimeType}:{}}:{type:t.type,...t.mimeType?{mimeType:t.mimeType}:{},...t.fileName?{fileName:t.fileName}:{}};this.transport.sendSessionUpdate(e,{sessionUpdate:"user_message_chunk",content:i,...s?{metadata:s}:{},...n?{timestamp:n}:{}},a)}}async replayAssistantTurnAsStream(e,t,s){const n=e=>{if(s||void 0!==e)return{...s?{turnId:s}:{},...void 0!==e?{seq:e}:{}}},i=n(t._seq);if(!t.sourceMessages?.length){const s={sessionUpdate:"history_turn",role:t.role,content:t.content,reasoning:t.reasoning,toolCalls:t.toolCalls,...t.timestamp?{timestamp:t.timestamp}:{}};return t.metadata&&(s.metadata=t.metadata),void this.transport.sendSessionUpdate(e,s,i)}for(const s of t.sourceMessages){const i=s.updateType??"agent_message_chunk",o=n(s._seq);switch(i){case"agent_message_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{},...s.metadata?{metadata:s.metadata}:{}},o);break;case"agent_thought_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{}},o);break;case"tool_call":{const n=s.toolCallId??"",i=t.toolCalls?.find(e=>e.id===n),r=s.toolName??"tool",a=s.content??"";let l={};if(a)try{l=JSON.parse(a)}catch{}this.transport.sendSessionUpdate(e,buildToolCallPayload({toolCallId:n,toolName:r,args:l,kind:i?.kind,status:i?.status??"in_progress",timestamp:s.timestamp}),o);break}case"tool_call_update":{const n=s.toolCallId??"",i=t.toolCalls?.find(e=>e.id===n),r=s.toolName??i?.name??"tool",a=s.content??i?.output??"";let l=await readEnrichmentCache(n).catch(()=>{});if(!l){const e=extractBeeosFileUrisFromOutput(a);e.length>0&&(l=e.map(e=>({uri:e,name:"file"})))}if(!l&&"beeos_upload_file"===r){const e=await readUploadToolResult(n).catch(()=>{});if(e){const t=buildToolResultSessionContentBlocks(r,e).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}:{}}));t.length>0&&(l=t)}}this.transport.sendSessionUpdate(e,buildToolCallUpdatePayload({toolCallId:n,toolName:r,outputText:a,kind:i?.kind,status:i?.status??"completed",resourceLinks:l,timestamp:s.timestamp}),o);break}}}}emitPromptAsUserSessionUpdates(e,t,s){const n=(new Date).toISOString(),i=s?{turnId:s}:void 0;for(const o of t){const t=this.toUserSessionUpdateContent(o);if(!t)continue;const r={sessionUpdate:"user_message_chunk",content:t,timestamp:n};s&&void 0===r._turnId&&(r._turnId=s),this.transport.sendSessionUpdate(e.sessionId,r,i),e.pendingUserTurnUpdates?.push(r)}}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,n=t.mimeType??t.mime_type??e.mimeType??e.mime_type,i=t.text??e.text,o=t.data??e.data,r=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!i&&!o)return;return{type:"resource",resource:{...s?{uri:s}:{},...n?{mimeType:n}:{},...i?{text:i}:{},...o?{data:o}:{},...r?{fileName:r}:{}}}}}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 n=this.state.sessions.get(s);if(!n)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const i=this.getGateway(),o=i?.isConnected,r=!(!n.currentRunId&&!n.gatewayRequestId);if(o&&r){i.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId})}this.endRun(n,{kind:"complete",stopReason:"cancelled"})||this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"stream_complete",stopReason:"cancelled"},{turnId:resolveTurnId(n)}),void 0!==e&&this.transport.sendResult(e,{})}endRun(e,t){if(e.promptDone)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)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;const s=resolveTurnId(e),n=s?{turnId:s}:void 0;e.promptDone=!0;const i=e.currentRunId;if("complete"===t.kind){if(i&&this.onRunComplete){const t=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(i,t)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:t.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:t.stopReason},n),this.tryGenerateTitleFromHistory(e,e.sessionId)}else i&&this.onRunFailed&&this.onRunFailed(i,t.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,t.code,t.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:t.message},n);return this.cleanupPrompt(e),this.onRunEnded?.(e,t),!0}schedulePromptTimeout(e){this.state.clearPromptTimeout(e);const t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{void 0!==e.pendingPromptId&&(this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),this.failPrompt(e,Errors.PromptTimeout.rpcCode,`Prompt timed out after ${t}ms`))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.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.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}completePrompt(e,t){this.endRun(e,{kind:"complete",stopReason:t})}failPrompt(e,t,s){this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())this.failPrompt(e,Errors.GatewayDisconnected.rpcCode,Errors.GatewayDisconnected.message)}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 n=e.error,i=n?.message??"gateway returned error";return this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${i}`),void this.failPrompt(s,Errors.SendFailed.rpcCode,i)}const n=e.payload,i=n?.runId;i?(s.currentRunId=i,s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(i,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${i} 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)this.completePrompt(e,"end_turn");else if("aborted"===s||"cancelled"===s||"cancel"===s)this.completePrompt(e,"cancelled");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";this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}pushToolResultContent(e,t,s,n){if(0===s.length)return;const i=n?this.state.promptsByRunId.get(n):void 0;if(!i)return void this.logger.warn?.(`[bridge-core] pushToolResultContent dropped — no session for runId=${n??"(none)"}`);const o=i.sessionId,r=(new Date).toISOString(),a={turnId:resolveTurnId(i)};for(const e of s){const t=narrowContentBlock(e);if(!t)continue;const s={sessionUpdate:"agent_message_chunk",content:t,timestamp:r};this.transport.sendSessionUpdate(o,s,a),i.assistantMq?.push(s)}this.writeObs({component:"bridge-core",domain:"tool",name:"tool_result.direct_push",severity:"info",payload:{toolCallId:e,toolName:t,blockCount:s.length,sessionId:o}})}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),n={...e.params??{}};if("cron.add"!==s&&"cron.update"!==s||!0!==n._beeos_internal)void 0!==n._beeos_internal&&delete n._beeos_internal;else{delete n._beeos_internal;const e=await this.allocateCronChannel(n.name??"cron");e&&(n.description=encodeBeeosChannelTag(n.description,e))}try{const i=await t.request(s,n);if(this.transport.sendResult(e.id,i??{}),("cron.add"===s||"cron.update"===s)&&i&&"object"==typeof i){const e=i.id,t=decodeBeeosChannelId(n.description);e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const n=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${n}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:n})}}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")}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 n=new Set;for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&n.add(s)}this.cronIsolatedSessionKeys=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 n;for(let i=0;i<=s;i++){const o=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(o.ok)return Buffer.from(await o.arrayBuffer());if(429===o.status||o.status>=500){const r=Number(o.headers.get("retry-after"))||0,a=1e3*Math.max(r,2**i);if(n=new Error(`HTTP ${o.status} from ${e}`),i<s){t.logger?.info?.(`[bridge-core] Download ${o.status}, retry ${i+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${o.status}`)}throw n??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),n=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(n,{recursive:!0}),s.extractAllTo(n,!0);const e=await resolveSkillRoot(n),i=await readdir(e,{withFileTypes:!0});let o=0;for(const s of i){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const n=join(e,s.name),i=join(t,s.name);await rename(n,i).catch(async()=>{await cp(n,i,{recursive:!0})}),o++}if(0===o)throw new Error("Zip archive contained no extractable files")}finally{await rm(n,{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),n=join(e,s),i=resolve(e),o=resolve(n),r=relative(i,o);if(!r||".."===r||r.startsWith(`..${sep}`)||path.isAbsolute(r))throw new Error("Invalid skill target path");return n}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{AcpGatewayEvents,isChannelGoneError}from"./gateway-events.js";import{AcpGatewaySessionState}from"./session-state.js";function makeHarness(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new AcpGatewaySessionState(e),a={sendSessionUpdate:vi.fn().mockReturnValue(!0),sendResult:vi.fn(),sendAcpError:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},t=vi.fn(),s=new Map;return{events:new AcpGatewayEvents({state:n,transport:a,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),completePrompt:vi.fn(),failPrompt:vi.fn(),maybeCompletePromptFromChatState:vi.fn(),gatewayRequest:t,cronChannelCache:s}),state:n,gatewayRequest:t,cronChannelCache:s,logger:e}}function installSession(e,n,a){const t={sessionId:`sid-${n}`,gatewaySessionKey:n,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set,channelId:a};e.sessions.set(t.sessionId,t),e.sessionsByGatewayKey.set(n,t)}describe("isChannelGoneError",()=>{it("returns true for 'not found' text",()=>{expect(isChannelGoneError(new Error("channel not found"),"channel not found")).toBe(!0)}),it("returns true for 'no such channel' / 'channel does not exist'",()=>{expect(isChannelGoneError(new Error("no such channel"),"no such channel")).toBe(!0),expect(isChannelGoneError(new Error("channel does not exist"),"channel does not exist")).toBe(!0)}),it("returns true for HTTP 404 / 410 in message",()=>{expect(isChannelGoneError(new Error("HTTP 404"),"HTTP 404")).toBe(!0),expect(isChannelGoneError(new Error("status 410"),"status 410")).toBe(!0)}),it("returns true when err carries a status=404 or 410 field",()=>{const e=Object.assign(new Error("nope"),{status:404}),n=Object.assign(new Error("nope"),{statusCode:410});expect(isChannelGoneError(e,"nope")).toBe(!0),expect(isChannelGoneError(n,"nope")).toBe(!0)}),it("returns false for unrelated errors",()=>{expect(isChannelGoneError(new Error("ECONNRESET"),"ECONNRESET")).toBe(!1),expect(isChannelGoneError(new Error("timeout"),"timeout")).toBe(!1),expect(isChannelGoneError(new Error("500 server error"),"500 server error")).toBe(!1)})}),describe("maybeBackfillBeeosChannel",()=>{it("short-circuits when cronChannelCache already holds the jobId",async()=>{const e=makeHarness();e.cronChannelCache.set("job-1","chan-cached"),await e.events.maybeBackfillBeeosChannel({jobId:"job-1",action:"added"}),expect(e.gatewayRequest).not.toHaveBeenCalled()}),it("skips and backfills cache when description already has the beeos tag",async()=>{const e=makeHarness();e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-2",description:"Daily backup\n\x3c!-- beeos:channel=chan-existing --\x3e",sessionKey:"agent:main:main",sessionTarget:"session:agent:main:main",payload:{kind:"agentTurn"}}]}),await e.events.maybeBackfillBeeosChannel({jobId:"job-2",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(1),expect(e.gatewayRequest).toHaveBeenCalledWith("cron.list",{}),expect(e.cronChannelCache.get("job-2")).toBe("chan-existing")}),it("skips when sessionTarget === 'main'",async()=>{const e=makeHarness();installSession(e.state,"agent:main:main","chan-origin"),e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-3",description:"",sessionKey:"agent:main:main",sessionTarget:"main",payload:{kind:"agentTurn"}}]}),await e.events.maybeBackfillBeeosChannel({jobId:"job-3",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(1),expect(e.cronChannelCache.has("job-3")).toBe(!1)}),it("skips when payload.kind === 'systemEvent'",async()=>{const e=makeHarness();installSession(e.state,"agent:main:main","chan-origin"),e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-4",description:"",sessionKey:"agent:main:main",sessionTarget:"session:agent:main:main",payload:{kind:"systemEvent",text:"heartbeat"}}]}),await e.events.maybeBackfillBeeosChannel({jobId:"job-4",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(1),expect(e.cronChannelCache.has("job-4")).toBe(!1)}),it("skips when delivery.mode === 'none' (explicit opt-out)",async()=>{const e=makeHarness();installSession(e.state,"agent:main:main","chan-origin"),e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-5",description:"Silent backup",sessionKey:"agent:main:main",sessionTarget:"session:agent:main:main",payload:{kind:"agentTurn"},delivery:{mode:"none"}}]}),await e.events.maybeBackfillBeeosChannel({jobId:"job-5",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(1),expect(e.cronChannelCache.has("job-5")).toBe(!1)}),it("skips when job has no sessionKey",async()=>{const e=makeHarness();e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-6",description:"",sessionTarget:"session:agent:main:main",payload:{kind:"agentTurn"}}]}),await e.events.maybeBackfillBeeosChannel({jobId:"job-6",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(1),expect(e.cronChannelCache.has("job-6")).toBe(!1)}),it("skips when ACPSession has no channelId",async()=>{const e=makeHarness();installSession(e.state,"agent:main:main",void 0),e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-7",description:"",sessionKey:"agent:main:main",sessionTarget:"session:agent:main:main",payload:{kind:"agentTurn"}}]}),await e.events.maybeBackfillBeeosChannel({jobId:"job-7",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(1),expect(e.cronChannelCache.has("job-7")).toBe(!1)}),it("happy path: emits cron.update with beeos tag and populates cache",async()=>{const e=makeHarness();installSession(e.state,"agent:main:main","chan-origin"),e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-8",description:"Remind me to pee",sessionKey:"agent:main:main",sessionTarget:"session:agent:main:main",payload:{kind:"agentTurn"},delivery:{mode:"announce",channel:"last"}}]}).mockResolvedValueOnce({ok:!0}),await e.events.maybeBackfillBeeosChannel({jobId:"job-8",action:"added"}),expect(e.gatewayRequest).toHaveBeenCalledTimes(2),expect(e.gatewayRequest).toHaveBeenNthCalledWith(1,"cron.list",{});const[n,a]=e.gatewayRequest.mock.calls[1];expect(n).toBe("cron.update"),expect(a.id).toBe("job-8"),expect(a.patch.description).toBe("Remind me to pee\n\x3c!-- beeos:channel=chan-origin --\x3e"),expect(e.cronChannelCache.get("job-8")).toBe("chan-origin")}),it("gracefully degrades (warn) when cron.update rejects",async()=>{const e=makeHarness();installSession(e.state,"agent:main:main","chan-origin"),e.gatewayRequest.mockResolvedValueOnce({jobs:[{id:"job-9",description:"Reminder",sessionKey:"agent:main:main",sessionTarget:"session:agent:main:main",payload:{kind:"agentTurn"}}]}).mockRejectedValueOnce(new Error("boom")),await expect(e.events.maybeBackfillBeeosChannel({jobId:"job-9",action:"added"})).resolves.toBeUndefined(),expect(e.logger.warn).toHaveBeenCalledWith(expect.stringContaining("maybeBackfillBeeosChannel cron.update failed")),expect(e.cronChannelCache.has("job-9")).toBe(!1)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayEvents}from"./gateway-events.js";import{AcpGatewaySessionState}from"./session-state.js";function makeDeps(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayEvents}from"./gateway-events.js";import{AcpGatewaySessionState}from"./session-state.js";function makeDeps(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),n=vi.fn().mockReturnValue(!0),s={sendSessionUpdate:n,sendResult:vi.fn(),sendAcpError:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a=vi.fn(),o=vi.fn(),i=vi.fn(),r=vi.fn(),d=vi.fn();return{events:new AcpGatewayEvents({state:t,transport:s,config:{gateway:{agentId:"main"}},logger:e,writeObs:d,completePrompt:a,failPrompt:o,maybeCompletePromptFromChatState:i,onStreamDelta:r}),state:t,transport:s,sendSessionUpdate:n,onStreamDelta:r,logger:e,writeObs:d}}function makeSession(e,t){const n={sessionId:"s1",gatewaySessionKey:"main/s1",agentId:"main",currentRunId:t,pendingPromptId:1,promptDone:!1,createdAt:Date.now(),chatReplayDedupKeys:new Set};return e.sessions.set(n.sessionId,n),e.promptsByRunId.set(t,n),n}describe("Bug-Z: heartbeat live-path filtering",()=>{let e,t;beforeEach(()=>{e=makeDeps(),t=makeSession(e.state,"run-heartbeat-1")}),it("drops agent.assistant delta whose cumulative text is HEARTBEAT_OK",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_OK"}};e.events.handleAgentEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(0),expect(e.onStreamDelta).not.toHaveBeenCalled()}),it("emits a structured SKIP log + obs event with reason + preview on prefix drop",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_"}};e.events.handleAgentEvent(t);const n=e.logger.info.mock.calls.map(e=>String(e[0])),s=n.find(e=>e.includes("SKIP heartbeat assistant-delta"));expect(s,`expected a SKIP log, got info=${JSON.stringify(n)}`).toBeDefined(),expect(s).toContain("reason=prefix"),expect(s).toContain("sessionId=s1"),expect(s).toContain("runId=run-heartbeat-1"),expect(s).toContain('cumulative="HEARTBEAT_"'),expect(s).toContain('delta="HEARTBEAT_"');const a=e.writeObs.mock.calls.map(e=>e[0]).find(e=>"heartbeat_filter_skip"===e.name);expect(a).toBeDefined(),expect(a.component).toBe("gateway-events"),expect(a.severity).toBe("info");const o=a.payload;expect(o.source).toBe("assistant-delta"),expect(o.reason).toBe("prefix"),expect(o.sessionId).toBe("s1"),expect(o.runId).toBe("run-heartbeat-1"),expect(o.cumulativePreview).toBe("HEARTBEAT_"),expect(o.deltaPreview).toBe("HEARTBEAT_")}),it('drops heartbeat ack across split deltas ("HEART" + "BEAT_OK") — no underscore or full token leaks',()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEART"}},n={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:2,ts:Date.now(),data:{delta:"BEAT_OK"}};e.events.handleAgentEvent(t),e.events.handleAgentEvent(n);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate).map(e=>e.content?.text??"").join("");expect(s).not.toContain("_"),expect(s).not.toContain("HEARTBEAT_OK")}),it('drops real-world split ("HEARTBEAT_" + "OK") — the ghost bubble case',()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_"}},n={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:2,ts:Date.now(),data:{delta:"OK"}};e.events.handleAgentEvent(t),e.events.handleAgentEvent(n);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(s).toHaveLength(0),expect(e.onStreamDelta).not.toHaveBeenCalled()}),it('drops fine-grained split ("H" + "EARTBEAT" + "_OK") at least at the underscore boundary',()=>{const t=["H","EARTBEAT","_OK"];for(let n=0;n<t.length;n++)e.events.handleAgentEvent({stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:n+1,ts:Date.now(),data:{delta:t[n]}});const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate).map(e=>e.content?.text??"").join("");expect(n).not.toContain("_"),expect(n).not.toContain("HEARTBEAT_OK")}),it("does NOT drop normal assistant text that happens to contain HEARTBEAT as substring",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"Let's design a HEARTBEAT_OK-like ack for our API."}};e.events.handleAgentEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(1),expect(n[0].content.text).toContain("HEARTBEAT_OK-like")}),it("drops chat.text heartbeat when no agent.assistant stream is active",()=>{const t={runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,message:{role:"assistant",content:[{type:"text",text:"HEARTBEAT_OK"}],timestamp:Date.now()}};e.events.handleChatEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(0)}),it("does NOT drop a real chat.text that only mentions the token inline",()=>{const t={runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,message:{role:"assistant",content:[{type:"text",text:"Use the sentinel HEARTBEAT_OK in your ack handler."}],timestamp:Date.now()}};e.events.handleChatEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(1)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash}from"node:crypto";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{stripTransportMetadata,normalizeTransportText,isHeartbeatOnlyText}from"../message-filter.js";import{resolveToolResultBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}function extractResourceLinksFromRawResult(t){if(!isRecord(t)||!Array.isArray(t.files))return;const e=[];for(const s of t.files){if(!isRecord(s))continue;const t=asString(s.uri)??(asString(s.fileId)?`beeos-file://${s.fileId}`:void 0);t&&e.push({uri:t,name:asString(s.name)??"file",...asString(s.mimeType)?{mimeType:asString(s.mimeType)}:{}})}return e.length>0?e:void 0}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const o=roleFromSessionUpdate(e);o&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[o]=s)}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;maybeCompletePromptFromChatState;agentFetchFn;onStreamDelta;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.onStreamDelta=t.onStreamDelta}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.completePrompt(t,"end_turn"))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s),this.onStreamDelta?.(t,s)}resolveToolCallId(t,e,s){const o=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(o){const e=this.state.toolCallIdMap.get(o);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(o,o),t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o)}const n=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const o of s.content){const n=o;if(!isRecord(n))continue;const a=asString(n.type);if(!a)continue;const r=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(n.thinking)??normalizeText(n.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const o={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,o)&&(e.assistantMq?.push(o),this.onStreamDelta?.(e,o));continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,n,"start"),s=asString(n.name)??asString(n.toolName)??asString(n.tool_name)??"tool",o=resolveToolArgs(n),a=dedupKey("chat:tool_start",t,JSON.stringify(o));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=buildToolCallPayload({toolCallId:t,toolName:s,args:o,timestamp:r});this.sendTurnUpdate(e,i)&&(e.assistantMq?.push(i),this.onStreamDelta?.(e,i));continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,n,"result"),s=asString(n.name)??asString(n.toolName)??asString(n.tool_name)??"tool",o=n.result,a=normalizeText(n.text)??normalizeText(n.result)??(isRecord(o)||Array.isArray(o)?JSON.stringify(o,null,2):void 0);if(!a)continue;const i=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(i))continue;e.chatReplayDedupKeys.add(i);const l=buildToolCallUpdatePayload({toolCallId:t,toolName:s,outputText:a,rawOutput:o,resourceLinks:extractResourceLinksFromRawResult(o),timestamp:r});this.sendTurnUpdate(e,l)&&(e.assistantMq?.push(l),this.onStreamDelta?.(e,l));continue}if("text"===a&&n.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(n.text);if(!s)continue;if(isHeartbeatOnlyText(s)){this.logger.debug?.(`[gateway-events] SKIP heartbeat chat.text runId=${t.runId} len=${s.length}`);continue}const o=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(o))continue;e.chatReplayDedupKeys.add(o);const a=e.chatAssistantTextSoFar??"";let i;if(e.chatAssistantTextSoFar=s,s.startsWith(a))i=s.slice(a.length);else{if(a.startsWith(s))continue;i=s}if(!i)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${i.length} prevLen=${a.length}`);const l={sessionUpdate:"agent_message_chunk",content:{type:"text",text:i},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,l),e.assistantMq?.push(l),this.onStreamDelta?.(e,l)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message;this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),o=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t),this.onStreamDelta?.(e,t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const n=this.state.extractAgentAssistantDelta(e,t.data??{});if(n){const a=normalizeTransportText(n);if(a){const n=e.agentAssistantTextSoFar??a;isHeartbeatOnlyText(n)?this.logger.debug?.(`[gateway-events] SKIP heartbeat assistant delta runId=${t.runId} len=${a.length}`):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),o({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],r=t.data;r&&(Array.isArray(r.content)?a.push(...r.content):isRecord(r.content)?a.push(r.content):"string"==typeof r.type&&a.push(r));for(const t of a){if(!isRecord(t))continue;const n=asString(t.type);if("thinking"===n){e.hasAgentThinkingStream=!0;const o=normalizeText(t.thinking)??normalizeText(t.text);if(o){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:o},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&(e.assistantMq?.push(t),this.onStreamDelta?.(e,t))}continue}if("toolCall"===n){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const n=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=resolveToolArgs(t);o(buildToolCallPayload({toolCallId:n,toolName:a,args:r,timestamp:s}));continue}if("toolResult"===n){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const n=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=t.result,i=normalizeText(t.text)??normalizeText(t.result)??(isRecord(r)||Array.isArray(r)?JSON.stringify(r,null,2):void 0);i&&o(buildToolCallUpdatePayload({toolCallId:n,toolName:a,outputText:i,rawOutput:r,resourceLinks:extractResourceLinksFromRawResult(r),timestamp:s}));continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const o=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(o){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:o},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&(e.assistantMq?.push(t),this.onStreamDelta?.(e,t))}}else if("tool"===t.stream){const n=t.data,a="string"==typeof n?.phase?n.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,n,"start"),a=asString(n?.name)??asString(n?.toolName)??asString(n?.tool_name)??"tool",r=resolveToolArgs(n);o(buildToolCallPayload({toolCallId:t,toolName:a,args:r,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=n?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(n?.name)??asString(n?.toolName)??"tool",r={...n,...t},i=this.resolveToolCallId(e,r,"start"),l=resolveToolArgs(r);o(buildToolCallPayload({toolCallId:i,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,o="string"==typeof s?.phase?s.phase:void 0;if("end"===o)this.completePrompt(e,"end_turn");else if("cancelled"===o||"cancel"===o)this.completePrompt(e,"cancelled");else if("error"===o){const o=s?.error,n=o&&"object"==typeof o?o:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof o?o:void 0)??("string"==typeof n?.message?n.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(n?JSON.stringify(n):void 0)??"gateway lifecycle error",r=formatFriendlyError(a);this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`),this.failPrompt(e,Errors.AgentError.rpcCode,r)}}}async handleToolResult(t,e,s){const o=e.data,n=o?.toolCall,a=("string"==typeof n?.id?n.id:void 0)??("string"==typeof o?.toolCallId?o.toolCallId:void 0),r=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,i=("string"==typeof n?.name?n.name:void 0)??("string"==typeof o?.name?o.name:void 0)??"tool",l="string"==typeof o?.text?o.text:void 0,d=a??r,c=o?.result;let u;try{u=await resolveToolResultBlocks({toolCallId:d,toolName:i,rawText:l,rawResult:c})}catch{u=l?[{type:"content",content:{type:"text",text:l}}]:[]}0===u.length&&l&&(u=[{type:"content",content:{type:"text",text:l}}]);const m=[],p=[];for(const t of u)t.content&&("resource_link"===t.content.type&&t.content.uri?p.push({uri:t.content.uri,name:t.content.name??"file",...t.content.mimeType?{mimeType:t.content.mimeType}:{}}):t.content.text&&m.push(t.content.text));const h=buildToolCallUpdatePayload({toolCallId:r,toolName:i,outputText:m.join("\n"),rawOutput:c,resourceLinks:p,timestamp:s});this.sendTurnUpdate(t,h)&&(t.assistantMq?.push(h),this.onStreamDelta?.(t,h))}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear()}handleCronEvent(t){if(this.transport.sendNotification("_beeos/cron_event",{payload:t}),!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e=t??{},s="string"==typeof e.sessionKey?e.sessionKey:void 0,o=s?this.state.findSessionByKey(s)?.sessionId:void 0,n={...e};s&&null==n.sessionKey&&(n.sessionKey=s),o&&(n.sessionId=o);const a=`${this.config.agentGatewayUrl}/api/v1/agent/events`;this.agentFetchFn(a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:n})}).catch(t=>{this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)})}}
|
|
1
|
+
import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{formatFriendlyError}from"../errors/classify.js";import{stripTransportMetadata,normalizeTransportText,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{resolveToolResultBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}function extractResourceLinksFromRawResult(t){if(!isRecord(t)||!Array.isArray(t.files))return;const e=[];for(const s of t.files){if(!isRecord(s))continue;const t=asString(s.uri)??(asString(s.fileId)?`beeos-file://${s.fileId}`:void 0);t&&e.push({uri:t,name:asString(s.name)??"file",...asString(s.mimeType)?{mimeType:asString(s.mimeType)}:{}})}return e.length>0?e:void 0}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const n=roleFromSessionUpdate(e);n&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[n]=s)}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;maybeCompletePromptFromChatState;agentFetchFn;onStreamDelta;getMessageSdk;getMsgPrincipalId;gatewayRequest;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.onStreamDelta=t.onStreamDelta,this.getMessageSdk=t.getMessageSdk,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.cronChannelCache=t.cronChannelCache}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.completePrompt(t,"end_turn"))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s),this.onStreamDelta?.(t,s)}resolveToolCallId(t,e,s){const n=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(n){const e=this.state.toolCallIdMap.get(n);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(n,n),t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n)}const o=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const n of s.content){const o=n;if(!isRecord(o))continue;const a=asString(o.type);if(!a)continue;const r=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(o.thinking)??normalizeText(o.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const n={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,n)&&(e.assistantMq?.push(n),this.onStreamDelta?.(e,n));continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,o,"start"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=resolveToolArgs(o),a=dedupKey("chat:tool_start",t,JSON.stringify(n));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=buildToolCallPayload({toolCallId:t,toolName:s,args:n,timestamp:r});this.sendTurnUpdate(e,i)&&(e.assistantMq?.push(i),this.onStreamDelta?.(e,i));continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,o,"result"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=o.result,a=normalizeText(o.text)??normalizeText(o.result)??(isRecord(n)||Array.isArray(n)?JSON.stringify(n,null,2):void 0);if(!a)continue;const i=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(i))continue;e.chatReplayDedupKeys.add(i);const l=buildToolCallUpdatePayload({toolCallId:t,toolName:s,outputText:a,rawOutput:n,resourceLinks:extractResourceLinksFromRawResult(n),timestamp:r});this.sendTurnUpdate(e,l)&&(e.assistantMq?.push(l),this.onStreamDelta?.(e,l));continue}if("text"===a&&o.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(o.text);if(!s)continue;const n=classifyHeartbeatFilter(s);if(n.filtered){this.logHeartbeatSkip({source:"chat-text",reason:n.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(i))l=s.slice(i.length);else{if(i.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${i.length}`);const d={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,d),e.assistantMq?.push(d),this.onStreamDelta?.(e,d)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message;this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),n=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t),this.onStreamDelta?.(e,t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const o=this.state.extractAgentAssistantDelta(e,t.data??{});if(o){const a=normalizeTransportText(o);if(a){const o=e.agentAssistantTextSoFar??a,r=classifyHeartbeatFilter(o);r.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:r.reason,sessionId:e.sessionId,runId:t.runId,cumulative:o,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),n({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],r=t.data;r&&(Array.isArray(r.content)?a.push(...r.content):isRecord(r.content)?a.push(r.content):"string"==typeof r.type&&a.push(r));for(const t of a){if(!isRecord(t))continue;const o=asString(t.type);if("thinking"===o){e.hasAgentThinkingStream=!0;const n=normalizeText(t.thinking)??normalizeText(t.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&(e.assistantMq?.push(t),this.onStreamDelta?.(e,t))}continue}if("toolCall"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const o=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=resolveToolArgs(t);n(buildToolCallPayload({toolCallId:o,toolName:a,args:r,timestamp:s}));continue}if("toolResult"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const o=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=t.result,i=normalizeText(t.text)??normalizeText(t.result)??(isRecord(r)||Array.isArray(r)?JSON.stringify(r,null,2):void 0);i&&n(buildToolCallUpdatePayload({toolCallId:o,toolName:a,outputText:i,rawOutput:r,resourceLinks:extractResourceLinksFromRawResult(r),timestamp:s}));continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const n=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&(e.assistantMq?.push(t),this.onStreamDelta?.(e,t))}}else if("tool"===t.stream){const o=t.data,a="string"==typeof o?.phase?o.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,o,"start"),a=asString(o?.name)??asString(o?.toolName)??asString(o?.tool_name)??"tool",r=resolveToolArgs(o);n(buildToolCallPayload({toolCallId:t,toolName:a,args:r,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=o?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(o?.name)??asString(o?.toolName)??"tool",r={...o,...t},i=this.resolveToolCallId(e,r,"start"),l=resolveToolArgs(r);n(buildToolCallPayload({toolCallId:i,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,n="string"==typeof s?.phase?s.phase:void 0;if("end"===n)this.completePrompt(e,"end_turn");else if("cancelled"===n||"cancel"===n)this.completePrompt(e,"cancelled");else if("error"===n){const n=s?.error,o=n&&"object"==typeof n?n:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof n?n:void 0)??("string"==typeof o?.message?o.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(o?JSON.stringify(o):void 0)??"gateway lifecycle error",r=formatFriendlyError(a);this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`),this.failPrompt(e,Errors.AgentError.rpcCode,r)}}}async handleToolResult(t,e,s){const n=e.data,o=n?.toolCall,a=("string"==typeof o?.id?o.id:void 0)??("string"==typeof n?.toolCallId?n.toolCallId:void 0),r=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,i=("string"==typeof o?.name?o.name:void 0)??("string"==typeof n?.name?n.name:void 0)??"tool",l="string"==typeof n?.text?n.text:void 0,d=a??r,c=n?.result;let u;try{u=await resolveToolResultBlocks({toolCallId:d,toolName:i,rawText:l,rawResult:c})}catch{u=l?[{type:"content",content:{type:"text",text:l}}]:[]}0===u.length&&l&&(u=[{type:"content",content:{type:"text",text:l}}]);const h=[],g=[];for(const t of u)t.content&&("resource_link"===t.content.type&&t.content.uri?g.push({uri:t.content.uri,name:t.content.name??"file",...t.content.mimeType?{mimeType:t.content.mimeType}:{}}):t.content.text&&h.push(t.content.text));const m=buildToolCallUpdatePayload({toolCallId:r,toolName:i,outputText:h.join("\n"),rawOutput:c,resourceLinks:g,timestamp:s});this.sendTurnUpdate(t,m)&&(t.assistantMq?.push(m),this.onStreamDelta?.(t,m))}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue)}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{},s="string"==typeof e.sessionKey?e.sessionKey:void 0,n=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(this.config.agentGatewayUrl&&this.agentFetchFn){const t={...e};s&&null==t.sessionKey&&(t.sessionKey=s),n&&(t.sessionId=n);const o=`${this.config.agentGatewayUrl}/api/v1/agent/events`;this.agentFetchFn(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:t})}).catch(t=>{this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)})}"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),n=t?.jobs;if(Array.isArray(n))for(const t of n){if(!t||"object"!=typeof t)continue;const n=t;if(n.id===e){s=n;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const n="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(n)){const t=decodeBeeosChannelId(n);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const o=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(o&&"string"==typeof o.kind?o.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const r="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!r)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const i=this.state.findSessionByKey(r),l=i?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${r} job=${e}`);const d=encodeBeeosChannelTag(n,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:d}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageSdk?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const n="string"==typeof t.jobId?t.jobId:void 0;if(!n)return;const o=t.summary;let a=this.cronChannelCache?.get(n);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===n){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const r=randomUUID();try{await e.sendToChannel(a,"agent_reply",{text:o,cron_job_id:n,session_key:t.sessionKey??void 0},{inReplyTo:r}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${n}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${n} err=${e}`),void this.cronChannelCache?.delete(n);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewFilteredText(t.cumulative),s=void 0!==t.delta?previewFilteredText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{listMcpServers,setMcpServer,unsetMcpServer,OpenClawCliError}from"../openclaw-cli.js";import{Errors}from"../errors/index.js";const RESTART_DEBOUNCE_MS=750;export class McpService{transport;logger;state;exit;restartTimer;constructor(r){this.transport=r.transport,this.logger=r.logger,this.state=r.state,this.exit=r.exit??(r=>process.exit(r))}async handleList(r){try{const e=await listMcpServers();this.transport.sendResult(r.id,{servers:e})}catch(e){this.logger.warn?.(`[mcp-service] list failed: ${e instanceof Error?e.message:e}`),this.sendMappedError(r,e,!0)}}async handleSet(r){const e=r.params,s=e?.name,t=e?.server;if(s&&t&&"object"==typeof t){if(!this.rejectIfInFlight(r))try{await setMcpServer(s,t),this.logger.info?.(`[mcp-service] set applied name=${s}`),this.transport.sendResult(r.id,{ok:!0,restarting:!0}),this.scheduleRestart()}catch(e){this.logger.error?.(`[mcp-service] set failed name=${s}: ${e instanceof Error?e.message:e}`),this.sendMappedError(r,e,!1)}}else this.transport.sendAcpError(r.id,Errors.InvalidParams,{message:"Missing 'name' or 'server' config"})}async handleUnset(r){const e=r.params,s=e?.name;if(s){if(!this.rejectIfInFlight(r))try{await unsetMcpServer(s),this.logger.info?.(`[mcp-service] unset applied name=${s}`),this.transport.sendResult(r.id,{ok:!0,restarting:!0}),this.scheduleRestart()}catch(e){this.logger.error?.(`[mcp-service] unset failed name=${s}: ${e instanceof Error?e.message:e}`),this.sendMappedError(r,e,!1)}}else this.transport.sendAcpError(r.id,Errors.InvalidParams,{message:"Missing 'name'"})}hasInFlightPrompts(){for(const r of this.state.sessions.values())if(void 0!==r.pendingPromptId||void 0!==r.currentRunId)return!0;return!1}rejectIfInFlight(r){return!!this.hasInFlightPrompts()&&(this.transport.sendError(r.id,Errors.InvalidParams.rpcCode,"Agent is busy",{code:Errors.InvalidParams.code,reason:"PROMPT_IN_FLIGHT",message:"Agent is processing a message; please retry after it completes."}),!0)}scheduleRestart(){this.restartTimer&&clearTimeout(this.restartTimer),this.restartTimer=setTimeout(()=>{this.logger.info?.("[mcp-service] mcp change applied — exiting for container restart"),this.exit(0)},750)}sendMappedError(r,e,s){const t=cliErrToAcpError(e,s);this.transport.sendError(r.id,t.code,t.message,t.data)}}export function cliErrToAcpError(r,e){if(r instanceof OpenClawCliError){const s=r.stderr??"";return null===r.exitCode||e?{code:Errors.InternalError.rpcCode,message:r.message,data:{code:Errors.InternalError.code}}:/No MCP server named/i.test(s)?{code:Errors.InvalidParams.rpcCode,message:r.message,data:{code:Errors.InvalidParams.code,reason:"NOT_FOUND"}}:{code:Errors.InvalidParams.rpcCode,message:r.message,data:{code:Errors.InvalidParams.code}}}return{code:Errors.InternalError.rpcCode,message:r instanceof Error?r.message:String(r),data:{code:Errors.InternalError.code}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";vi.mock("../openclaw-cli.js",()=>{class e extends Error{exitCode;stderr;constructor(e,t,r){super(e),this.exitCode=t,this.stderr=r,this.name="OpenClawCliError"}}return{OpenClawCliError:e,listMcpServers:vi.fn(),setMcpServer:vi.fn(),unsetMcpServer:vi.fn()}});import{listMcpServers as mockedList,setMcpServer as mockedSet,unsetMcpServer as mockedUnset,OpenClawCliError}from"../openclaw-cli.js";import{McpService,cliErrToAcpError}from"./mcp-service.js";import{Errors}from"../errors/index.js";import{AcpGatewaySessionState}from"./session-state.js";function makeTransport(){const e=[];return{transport:{sendResult(t,r){e.push({kind:"result",id:t,result:r})},sendError(t,r,s,o){e.push({kind:"error",id:t,code:r,message:s,data:o})},sendAcpError(t,r,s){const o={code:r.code};s?.param&&(o.param=s.param),e.push({kind:"error",id:t,code:r.rpcCode,message:s?.message??r.message,data:o})},sendSessionUpdate:()=>{},sendNotification:()=>{}},sent:e}}function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeSession(e,t={}){return{sessionId:e,gatewaySessionKey:`agent:main:${e}`,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set,...t}}function makeService(){const{transport:e,sent:t}=makeTransport(),r=makeLogger(),s=new AcpGatewaySessionState(r),o=vi.fn();return{svc:new McpService({transport:e,logger:r,state:s,exit:o}),sent:t,state:s,exit:o}}const setReq=(e,t="foo",r={transport:"sse",url:"https://x"})=>({jsonrpc:"2.0",id:e,method:"mcp/set",params:{name:t,server:r}}),unsetReq=(e,t="foo")=>({jsonrpc:"2.0",id:e,method:"mcp/unset",params:{name:t}});beforeEach(()=>{vi.useFakeTimers(),vi.mocked(mockedList).mockReset(),vi.mocked(mockedSet).mockReset(),vi.mocked(mockedUnset).mockReset()}),afterEach(()=>{vi.useRealTimers()}),describe("McpService.handleSet",()=>{it("writes via CLI, replies {ok, restarting:true} and schedules one exit(0) after 750ms",async()=>{const{svc:e,sent:t,exit:r}=makeService();vi.mocked(mockedSet).mockResolvedValue(void 0),await e.handleSet(setReq(1)),expect(vi.mocked(mockedSet)).toHaveBeenCalledTimes(1),expect(t).toEqual([{kind:"result",id:1,result:{ok:!0,restarting:!0}}]),expect(r).not.toHaveBeenCalled(),vi.advanceTimersByTime(749),expect(r).not.toHaveBeenCalled(),vi.advanceTimersByTime(1),expect(r).toHaveBeenCalledTimes(1),expect(r).toHaveBeenCalledWith(0)}),it("rejects with InvalidParams when name or server missing",async()=>{const{svc:e,sent:t,exit:r}=makeService();await e.handleSet({jsonrpc:"2.0",id:1,method:"mcp/set",params:{name:"foo"}}),expect(vi.mocked(mockedSet)).not.toHaveBeenCalled(),expect(r).not.toHaveBeenCalled(),expect(t).toHaveLength(1),expect(t[0].kind).toBe("error"),expect(t[0].code).toBe(Errors.InvalidParams.rpcCode)})}),describe("McpService debounce",()=>{it("coalesces multiple rapid set/unset successes into a single exit(0)",async()=>{const{svc:e,exit:t}=makeService();vi.mocked(mockedSet).mockResolvedValue(void 0),vi.mocked(mockedUnset).mockResolvedValue(void 0),await e.handleSet(setReq(1,"a")),vi.advanceTimersByTime(300),await e.handleSet(setReq(2,"b")),vi.advanceTimersByTime(300),await e.handleUnset(unsetReq(3,"a")),vi.advanceTimersByTime(749),expect(t).not.toHaveBeenCalled(),vi.advanceTimersByTime(1),expect(t).toHaveBeenCalledTimes(1)})}),describe("McpService in-flight prompt gate",()=>{it("rejects handleSet with PROMPT_IN_FLIGHT when a session has a pending prompt",async()=>{const{svc:e,state:t,sent:r,exit:s}=makeService(),o=makeSession("s1",{pendingPromptId:42});t.sessions.set("s1",o),await e.handleSet(setReq(1)),expect(vi.mocked(mockedSet)).not.toHaveBeenCalled(),expect(s).not.toHaveBeenCalled(),expect(r).toHaveLength(1);const a=r[0];expect(a.kind).toBe("error"),expect(a.code).toBe(Errors.InvalidParams.rpcCode),expect(a.data).toMatchObject({reason:"PROMPT_IN_FLIGHT"})}),it("rejects handleUnset with PROMPT_IN_FLIGHT when a session has an active run",async()=>{const{svc:e,state:t,sent:r}=makeService();t.sessions.set("s1",makeSession("s1",{currentRunId:"run-1"})),await e.handleUnset(unsetReq(7)),expect(vi.mocked(mockedUnset)).not.toHaveBeenCalled();const s=r.at(-1);expect(s.kind).toBe("error"),expect(s.data).toMatchObject({reason:"PROMPT_IN_FLIGHT"})}),it("allows writes when all sessions are idle",async()=>{const{svc:e,state:t,exit:r}=makeService();vi.mocked(mockedSet).mockResolvedValue(void 0),t.sessions.set("s1",makeSession("s1")),t.sessions.set("s2",makeSession("s2")),await e.handleSet(setReq(1)),expect(vi.mocked(mockedSet)).toHaveBeenCalledTimes(1),vi.advanceTimersByTime(750),expect(r).toHaveBeenCalledWith(0)})}),describe("cliErrToAcpError",()=>{it("maps null exit code (spawn/timeout) to InternalError regardless of readOnly",()=>{const e=new OpenClawCliError("spawn failed",null,"");expect(cliErrToAcpError(e,!1).code).toBe(Errors.InternalError.rpcCode),expect(cliErrToAcpError(e,!0).code).toBe(Errors.InternalError.rpcCode)}),it("treats any CLI error as InternalError when readOnly=true",()=>{const e=new OpenClawCliError("bad schema",1,"zod: invalid url"),t=cliErrToAcpError(e,!0);expect(t.code).toBe(Errors.InternalError.rpcCode)}),it("maps 'No MCP server named' stderr to InvalidParams + reason=NOT_FOUND",()=>{const e=new OpenClawCliError("openclaw CLI exited with code 1",1,'No MCP server named "foo" in /data/openclaw.json.'),t=cliErrToAcpError(e,!1);expect(t.code).toBe(Errors.InvalidParams.rpcCode),expect(t.data).toMatchObject({reason:"NOT_FOUND"})}),it("maps generic non-zero CLI exit to InvalidParams without reason",()=>{const e=new OpenClawCliError("openclaw CLI exited with code 1",1,"zod: invalid url"),t=cliErrToAcpError(e,!1);expect(t.code).toBe(Errors.InvalidParams.rpcCode),expect(t.data.reason).toBeUndefined()}),it("maps non-CLI errors to InternalError",()=>{const e=cliErrToAcpError(new Error("network blip"),!1);expect(e.code).toBe(Errors.InternalError.rpcCode),expect(e.message).toBe("network blip")})}),describe("McpService.handleList",()=>{it("returns {servers} on success",async()=>{const{svc:e,sent:t,exit:r}=makeService();vi.mocked(mockedList).mockResolvedValue({foo:{transport:"sse",url:"https://x"}}),await e.handleList({jsonrpc:"2.0",id:9,method:"mcp/list"}),expect(t).toEqual([{kind:"result",id:9,result:{servers:{foo:{transport:"sse",url:"https://x"}}}}]),expect(r).not.toHaveBeenCalled()}),it("maps CLI failure to InternalError (read-only path)",async()=>{const{svc:e,sent:t}=makeService();vi.mocked(mockedList).mockRejectedValue(new OpenClawCliError("openclaw CLI exited with code 1",1,"parse error")),await e.handleList({jsonrpc:"2.0",id:10,method:"mcp/list"});const r=t.at(-1);expect(r.kind).toBe("error"),expect(r.code).toBe(Errors.InternalError.rpcCode)})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{AcpTransport}from"./transport.js";function stampTurnIdOnUserUpdate(e,t){return t&&void 0===e._turnId&&(e._turnId=t),e}describe("pendingUserTurnUpdates _turnId stamp",()=>{it("stamps _turnId directly on the update object when turnId is provided",()=>{const e={sessionUpdate:"user_message_chunk",content:{type:"text",text:"Hello"}};stampTurnIdOnUserUpdate(e,"req:42"),expect(e._turnId).toBe("req:42")}),it("is a no-op when turnId is undefined",()=>{const e={sessionUpdate:"user_message_chunk",content:{type:"text",text:"Hello"}};stampTurnIdOnUserUpdate(e,void 0),expect(e._turnId).toBeUndefined()}),it("does not overwrite an existing _turnId (idempotent)",()=>{const e={sessionUpdate:"user_message_chunk",content:{type:"text",text:"Hello"},_turnId:"req:7"};stampTurnIdOnUserUpdate(e,"req:99"),expect(e._turnId).toBe("req:7")})}),describe("emitCurrentStreamSnapshot preserves _turnId from buffer",()=>{it("replays user updates with their stamped _turnId intact on the wire",()=>{const e=[],t=vi.fn(t=>e.push(t)),s=new AcpTransport(t,vi.fn()),n="req:prompt-1",r={sessionUpdate:"user_message_chunk",content:{type:"text",text:"Hello"},timestamp:"2024-01-01T00:00:00Z"};stampTurnIdOnUserUpdate(r,n);const o={sessionUpdate:"user_message_chunk",content:{type:"image",data:"base64..."},timestamp:"2024-01-01T00:00:00Z"};stampTurnIdOnUserUpdate(o,n);const p=[r,o];for(const e of p)s.sendSessionUpdate("sess-1",e);expect(e.length).toBe(2);for(const t of e){const e=t;expect(e.params?.update?._turnId).toBe(n)}}),it("user updates without _turnId arrive on the wire without it (pre-fix behavior)",()=>{const e=[],t=vi.fn(t=>e.push(t));new AcpTransport(t,vi.fn()).sendSessionUpdate("sess-1",{sessionUpdate:"user_message_chunk",content:{type:"text",text:"No turnId"}}),expect(e.length).toBe(1);const s=e[0];expect(s.params?.update?._turnId).toBeUndefined()})});
|