beeos-claw 0.5.52 → 0.5.54

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.
Files changed (44) hide show
  1. package/dist/acp-gateway/bridge-core-history-page.test.js +1 -1
  2. package/dist/acp-gateway/bridge-core.js +1 -1
  3. package/dist/acp-gateway/gateway-events.js +1 -1
  4. package/dist/acp-gateway/history-replay.test.js +1 -1
  5. package/dist/acp-gateway/history-turn-completion-policy.js +1 -1
  6. package/dist/acp-gateway/local-session-history.js +1 -1
  7. package/dist/acp-gateway/local-session-history.test.js +1 -1
  8. package/dist/acp-gateway/session-write-gate.js +1 -1
  9. package/dist/acp-gateway/session-write-gate.test.js +1 -1
  10. package/dist/acp-gateway/stale-session-lock-sweeper.js +1 -1
  11. package/dist/acp-server.js +1 -1
  12. package/dist/im-inbound-handler.js +1 -1
  13. package/dist/im-inbound-handler.test.js +1 -1
  14. package/dist/im-runtime-dispatch-session-guard.test.js +1 -0
  15. package/dist/im-runtime-dispatch.js +1 -1
  16. package/dist/im-runtime-dispatch.test.js +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/runtime/agent-runtime-failure-classifier.js +1 -0
  19. package/dist/{acp-gateway → runtime}/agent-runtime-failure-classifier.test.js +1 -1
  20. package/dist/runtime/im-reply-sink.js +1 -1
  21. package/dist/runtime/im-runtime-failure-policy.js +1 -0
  22. package/dist/runtime/im-runtime-failure-policy.test.js +1 -0
  23. package/dist/runtime/openclaw-process-ownership-hook-registrar.js +1 -0
  24. package/dist/runtime/openclaw-process-ownership-hook-registrar.test.js +1 -0
  25. package/dist/runtime/openclaw-process-ownership-hook.js +1 -0
  26. package/dist/runtime/openclaw-process-ownership-hook.test.js +1 -0
  27. package/dist/runtime/openclaw-session-write-quiescence.js +1 -0
  28. package/dist/runtime/openclaw-session-write-quiescence.test.js +1 -0
  29. package/dist/runtime/openclaw-state-dir.js +1 -0
  30. package/dist/runtime/process-session-terminator.js +1 -0
  31. package/dist/runtime/process-session-terminator.test.js +1 -0
  32. package/dist/runtime/prompt-run-store.js +1 -1
  33. package/dist/runtime/prompt-run-store.test.js +1 -1
  34. package/dist/runtime/reply-router.js +1 -1
  35. package/dist/runtime/reply-router.test.js +1 -1
  36. package/dist/runtime/run-process-lifecycle-coordinator.js +1 -0
  37. package/dist/runtime/run-process-lifecycle-coordinator.test.js +1 -0
  38. package/dist/runtime/run-process-ownership-registry.js +1 -0
  39. package/dist/runtime/run-process-ownership-registry.test.js +1 -0
  40. package/dist/runtime/session-write-gate-release-coordinator.js +1 -0
  41. package/dist/runtime/session-write-gate-release-coordinator.test.js +1 -0
  42. package/dist/test-fixtures/im-runtime.fixture.js +1 -0
  43. package/package.json +1 -1
  44. package/dist/acp-gateway/agent-runtime-failure-classifier.js +0 -1
@@ -1 +1 @@
1
- import{describe,expect,it,vi,beforeEach,afterEach}from"vitest";import{mkdtempSync,mkdirSync,rmSync,writeFileSync}from"node:fs";import os from"node:os";import path from"node:path";import{groupIntoTurns,loadHistory,parseGatewayHistory}from"./history-replay.js";import{LocalSessionHistory}from"./local-session-history.js";function makeGateway(e={}){const{connected:t=!0,delayMs:s=0,response:a={messages:[]},reject:o=!1}=e;return{get isConnected(){return t},chatHistory:vi.fn(async()=>{if(s>0&&await new Promise(e=>setTimeout(e,s)),o)throw new Error("rpc-failed");return a})}}describe("loadHistory",()=>{const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn()};let t;function s(e,s,a){const o=path.join(t,"agents",e,"sessions");mkdirSync(o,{recursive:!0});const n=path.join(o,`${s}.jsonl`),r=a.map(e=>JSON.stringify({id:e.id,type:"message",timestamp:(new Date).toISOString(),message:{role:e.role,content:[{type:"text",text:e.text}],timestamp:Date.now()}}));writeFileSync(n,r.join("\n")+"\n")}beforeEach(()=>{t=mkdtempSync(path.join(os.tmpdir(),"beeos-claw-lh-")),e.info.mockClear(),e.warn.mockClear(),e.debug.mockClear()}),afterEach(()=>{rmSync(t,{recursive:!0,force:!0})}),it("returns gateway messages when chat.history has content",async()=>{const t=makeGateway({response:{messages:[{id:"m1",role:"user",content:[{type:"text",text:"hi"}]},{id:"m2",role:"assistant",content:[{type:"text",text:"hello"}]}]}}),s=vi.fn(),a=await loadHistory(t,"agent:main:s1",100,{logger:e,emitObs:s});expect(a.length).toBeGreaterThan(0),expect(t.chatHistory).toHaveBeenCalledOnce(),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("source=chat.history")),expect(s).toHaveBeenCalledOnce(),expect(s).toHaveBeenCalledWith(expect.objectContaining({name:"history.source",severity:"info",payload:expect.objectContaining({source:"chat.history",messages:a.length})}))}),it("hydrates chat.history with local persisted toolResult file artifacts",async()=>{const s={status:"completed",result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"device-screenshots/screenshot.jpeg",mimeType:"image/jpeg"}]};!function(e,s,a){const o=path.join(t,"agents",e,"sessions");mkdirSync(o,{recursive:!0});const n=path.join(o,`${s}.jsonl`);writeFileSync(n,a.map(e=>JSON.stringify(e)).join("\n")+"\n")}("main","s1",[{id:"u1",type:"message",timestamp:"2026-07-02T18:02:44.475Z",message:{role:"user",content:[{type:"text",text:"@device /screenshot"}],timestamp:1783015364475}},{id:"a1",type:"message",timestamp:"2026-07-02T18:02:56.660Z",message:{role:"assistant",content:[{type:"toolCall",id:"tc-get-task",name:"beeos_get_task",arguments:{taskId:"task-1",agentId:"device"}}],timestamp:1783015376660}},{id:"tr1",type:"message",timestamp:"2026-07-02T18:02:57.046Z",message:{role:"toolResult",toolCallId:"tc-get-task",toolName:"beeos_get_task",content:[{type:"text",text:JSON.stringify({content:JSON.stringify(s)})}],details:{content:JSON.stringify(s)},isError:!1,timestamp:1783015377046}},{id:"a2",type:"message",timestamp:"2026-07-02T18:03:01.926Z",message:{role:"assistant",content:[{type:"text",text:"Done"}],timestamp:1783015381926}}]);const a=makeGateway({response:{messages:[{role:"user",content:[{type:"text",text:"@device /screenshot"}],timestamp:1783015364475,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[{type:"toolCall",id:"tc-get-task",name:"beeos_get_task",arguments:{taskId:"task-1",agentId:"device"}}],timestamp:1783015376660,__openclaw:{id:"a1",seq:2}},{role:"assistant",content:[{type:"text",text:"Done"}],timestamp:1783015381926,__openclaw:{id:"a2",seq:4}}]}}),o=new LocalSessionHistory({logger:e,openclawHome:t}),n=vi.fn(),r=(await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1",emitObs:n})).find(e=>"tool_call_update"===e.updateType&&"tc-get-task"===e.toolCallId);expect(r).toMatchObject({role:"assistant",updateType:"tool_call_update",toolCallId:"tc-get-task",toolName:"beeos_get_task",files:[{uri:"beeos-file://file-1",name:"device-screenshots/screenshot.jpeg",mimeType:"image/jpeg"}]}),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("source=chat.history+local-jsonl-artifacts")),expect(n).toHaveBeenCalledWith(expect.objectContaining({name:"history.source",severity:"info",payload:expect.objectContaining({source:"chat.history+local-jsonl-artifacts",addedFileArtifacts:1})}))}),it("falls back to local jsonl when gateway is disconnected",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"},{id:"m2",role:"assistant",text:"hello"}]);const a=makeGateway({connected:!1}),o=new LocalSessionHistory({logger:e,openclawHome:t}),n=vi.fn(),r=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1",emitObs:n});expect(r.length).toBeGreaterThan(0),expect(a.chatHistory).not.toHaveBeenCalled(),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("source=local-jsonl")),expect(n).toHaveBeenCalledWith(expect.objectContaining({name:"history.local_fallback",severity:"info",payload:expect.objectContaining({reason:"gateway-disconnected"})}))}),it("falls back to local jsonl when chat.history times out",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"}]);const a=makeGateway({delayMs:500}),o=new LocalSessionHistory({logger:e,openclawHome:t}),n=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1",rpcTimeoutMs:50});expect(n.length).toBeGreaterThan(0),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("reason=rpc-timeout"))}),it("falls back to local jsonl when chat.history returns empty",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"}]);const a=makeGateway({response:{messages:[]}}),o=new LocalSessionHistory({logger:e,openclawHome:t}),n=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1"});expect(n.length).toBeGreaterThan(0),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("reason=rpc-empty"))}),it("returns [] when both gateway and local are empty",async()=>{const s=makeGateway({response:{messages:[]}}),a=new LocalSessionHistory({logger:e,openclawHome:t}),o=vi.fn(),n=await loadHistory(s,"agent:main:s1",100,{logger:e,localHistory:a,agentId:"main",sessionId:"missing",emitObs:o});expect(n).toEqual([]),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("local-empty")),expect(o).toHaveBeenCalledWith(expect.objectContaining({name:"history.none",severity:"warn",payload:expect.objectContaining({fallbackAvailable:!0})}))}),it("falls back to local on rpc error",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"}]);const a=makeGateway({reject:!0}),o=new LocalSessionHistory({logger:e,openclawHome:t}),n=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1"});expect(n.length).toBeGreaterThan(0),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("reason=rpc-error"))})}),describe("parseGatewayHistory user envelope sanitization",()=>{it("replays production UTC same-line BeeOS user content without transport envelope",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:["Sender (untrusted metadata):","```json",'{"label":"BeeOS Platform Bridge (cli)"}',"```","[Tue 2026-07-07 12:07 UTC] User Message From BeeOS:","今天天气怎么样?"].join("\n")}],timestamp:1783426065672,__openclaw:{id:"8883d542",seq:14}}]});expect(e).toHaveLength(1),expect(e[0]).toMatchObject({role:"user",content:"今天天气怎么样?",messageId:"8883d542",_seq:14}),expect(e[0].content).not.toContain("Sender (untrusted metadata)"),expect(e[0].content).not.toContain("User Message From BeeOS"),expect(e[0].content).not.toContain("UTC]")}),it("does not sanitize assistant text that discusses BeeOS markers",()=>{const e="请解释 User Message From BeeOS: 这个字符串",t=parseGatewayHistory({messages:[{role:"assistant",content:[{type:"text",text:e}],__openclaw:{id:"a1",seq:1}}]});expect(t).toHaveLength(1),expect(t[0].content).toBe(e)})}),describe("parseGatewayHistory provider error replay",()=>{it("converts assistant stopReason=error + errorMessage (HTML) into a summarised bubble",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"你好"}],timestamp:17767039e5,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html><html><head><title>Bad gateway</title></head><body><h1>Bad gateway</h1><p>Cloudflare</p></body></html>",timestamp:1776703955154,__openclaw:{id:"a1",seq:2}}]});expect(e).toHaveLength(2);const t=e[1];expect(t.role).toBe("assistant"),expect(t.content).toContain("HTTP 502"),expect(t.content).not.toContain("<!DOCTYPE"),expect(t.content).not.toContain("<html"),expect(t.metadata).toMatchObject({status:"error",errorSource:"provider",errorCategory:"gateway",httpStatus:502,stopReason:"error"}),expect(t.metadata?.rawError).toContain("<!DOCTYPE"),expect(t.messageId).toBe("a1")}),it("passes through short plain-text error messages verbatim",()=>{const e=parseGatewayHistory({messages:[{role:"assistant",content:[],stopReason:"error",errorMessage:"rate_limit_exceeded: please slow down",timestamp:1776703955154,__openclaw:{id:"a2",seq:1}}]});expect(e).toHaveLength(1),expect(e[0].content).toBe("rate_limit_exceeded: please slow down"),expect(e[0].metadata).toMatchObject({status:"error",errorSource:"provider",errorCategory:"rate_limit"})}),it("truncates oversized plain-text error bodies",()=>{const e="x".repeat(2e3),t=parseGatewayHistory({messages:[{role:"assistant",content:[],stopReason:"error",errorMessage:e,__openclaw:{id:"a3",seq:1}}]});expect(t).toHaveLength(1),expect(t[0].content.length).toBeLessThan(e.length),expect(t[0].content.endsWith("…")).toBe(!0)}),it("does NOT reformat assistant messages whose normal content happens to contain HTML",()=>{const e="Sure, here is the snippet: <html><body><h1>Hello</h1></body></html>. The <h1> tag is a top-level heading.",t=parseGatewayHistory({messages:[{role:"assistant",content:[{type:"text",text:e}],errorMessage:"502 <html>should be ignored</html>",stopReason:"error",__openclaw:{id:"a5",seq:1}}]});expect(t).toHaveLength(1),expect(t[0].content).toBe(e),expect(t[0].content).toContain("<html>"),expect(t[0].metadata?.status).toBeUndefined(),expect(t[0].metadata?.errorCategory).toBeUndefined()}),it("collapses provider retry chains to a single terminal error entry (parentId linked)",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"hi"}],__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"u1",__openclaw:{id:"a1",seq:2}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"a1",__openclaw:{id:"a2",seq:3}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"a2",__openclaw:{id:"a3",seq:4}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"a3",__openclaw:{id:"a4",seq:5}}]});expect(e).toHaveLength(2),expect(e[0].role).toBe("user"),expect(e[1].role).toBe("assistant"),expect(e[1].messageId).toBe("a4"),expect(e[1].content).toContain("HTTP 502");const t=e[1].content.split("HTTP 502").length-1;expect(t).toBe(1)}),it("collapse fallback: missing __openclaw metadata still keeps only the last error in a contiguous run",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"hi"}]},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 1"},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 2 final"}]});expect(e).toHaveLength(2),expect(e[1].content).toBe("502 attempt 2 final")}),it("collapses sibling-style retries where every retry's parent is the same user (not a prior error)",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"hi"}],__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 1",parentId:"u1",__openclaw:{id:"a1",seq:2}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 2",parentId:"u1",__openclaw:{id:"a2",seq:3}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 3",parentId:"u1",__openclaw:{id:"a3",seq:4}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 4 final",parentId:"u1",__openclaw:{id:"a4",seq:5}}]}).filter(e=>"error"===e.metadata?.status);expect(e).toHaveLength(1),expect(e[0].messageId).toBe("a4"),expect(e[0].metadata?.rawError).toBe("502 attempt 4 final")}),it("does NOT collapse error chains that are separated by a successful reply",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"q1"}],__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 first turn",parentId:"u1",__openclaw:{id:"a1",seq:2}},{role:"user",content:[{type:"text",text:"q2"}],parentId:"a1",__openclaw:{id:"u2",seq:3}},{role:"assistant",content:[],stopReason:"error",errorMessage:"503 second turn",parentId:"u2",__openclaw:{id:"a2",seq:4}}]});expect(e).toHaveLength(4),expect(e[1].role).toBe("assistant"),expect(e[1].metadata?.status).toBe("error"),expect(e[3].role).toBe("assistant"),expect(e[3].metadata?.status).toBe("error")}),it("still drops genuinely empty assistant messages (no errorMessage, no content)",()=>{const e=parseGatewayHistory({messages:[{role:"assistant",content:[],__openclaw:{id:"a4",seq:1}}]});expect(e).toHaveLength(0)})}),describe("parseGatewayHistory deterministic ordering (Bug-W)",()=>{const e=[{role:"user",content:[{type:"text",text:"hi"}],timestamp:1776704e6,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[{type:"text",text:"hello"}],timestamp:1776704001e3,__openclaw:{id:"a1",seq:2}},{role:"user",content:[{type:"text",text:"what time is it"}],timestamp:1776704002e3,__openclaw:{id:"u2",seq:3}},{role:"assistant",content:[{type:"text",text:"3pm"}],timestamp:1776704003e3,__openclaw:{id:"a2",seq:4}}];it("produces the same sequence regardless of input order (shuffled inputs)",()=>{const t=parseGatewayHistory({messages:e}),s=parseGatewayHistory({messages:[...e].reverse()}),a=parseGatewayHistory({messages:[e[2],e[0],e[3],e[1]]});expect(t).toHaveLength(4),expect(s).toEqual(t),expect(a).toEqual(t);for(let e=1;e<t.length;e++)expect(t[e]._seq).toBeGreaterThan(t[e-1]._seq)}),it("preserves original order when any entry is missing __openclaw.seq",()=>{const t=parseGatewayHistory({messages:[e[0],e[1],{role:"assistant",content:[{type:"text",text:"oops"}]},e[2],e[3]]}),s=t.findIndex(e=>"assistant"===e.role&&e.content.includes("oops")),a=t.findIndex(e=>"user"===e.role&&e.content.includes("what time"));expect(s).toBeGreaterThan(-1),expect(a).toBeGreaterThan(s)}),it("sorts within a single turn that was split into multiple JSONL rows",()=>{const e=parseGatewayHistory({messages:[{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc_1",content:"42"}],timestamp:1776704011e3,__openclaw:{id:"tr1",seq:13}},{role:"user",content:[{type:"text",text:"go"}],timestamp:1776704009e3,__openclaw:{id:"u9",seq:10}},{role:"assistant",content:[{type:"tool_use",id:"tc_1",name:"calc",input:{a:6,b:7}}],timestamp:177670401e4,__openclaw:{id:"a9",seq:12}},{role:"assistant",content:[{type:"text",text:"let me compute"}],timestamp:177670401e4,__openclaw:{id:"a9",seq:11}}]}).map(e=>e._seq);for(let t=1;t<e.length;t++)expect(e[t]).toBeGreaterThanOrEqual(e[t-1])}),it("replays persisted role=toolResult file artifacts as turn files",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"screenshot"}],timestamp:1776704009e3,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[{type:"tool_use",id:"tc_1",name:"beeos_get_task",input:{taskId:"task-1"}}],timestamp:177670401e4,__openclaw:{id:"a1",seq:2}},{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc_1",name:"beeos_get_task",content:JSON.stringify({status:"completed",result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}],timestamp:1776704011e3,__openclaw:{id:"tr1",seq:3}},{role:"assistant",content:[{type:"text",text:"Done"}],timestamp:1776704012e3,__openclaw:{id:"a2",seq:4}}]}),t=e.find(e=>"tool_call_update"===e.updateType),s=e.findIndex(e=>"tool_call_update"===e.updateType),a=e.findIndex(e=>"assistant"===e.role&&"Done"===e.content);expect(s).toBeGreaterThanOrEqual(0),expect(a).toBeGreaterThan(s),expect(t).toMatchObject({content:"",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]});const o=groupIntoTurns(e).find(e=>"assistant"===e.role&&e.toolCalls?.length);expect(o?.files).toEqual([{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]),expect(o?.toolCalls?.[0]?.output).toBe("")}),it("does not replay text-only beeos-file URI tool results as files",()=>{const e=parseGatewayHistory({messages:[{role:"assistant",content:[{type:"tool_use",id:"tc_1",name:"beeos_get_task",input:{}}],timestamp:177670401e4,__openclaw:{id:"a1",seq:1}},{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc_1",name:"beeos_get_task",content:"beeos-file://file-1"}],timestamp:1776704011e3,__openclaw:{id:"tr1",seq:2}}]}),t=e.find(e=>"tool_call_update"===e.updateType);expect(t).toMatchObject({content:"beeos-file://file-1"}),expect(t?.files).toBeUndefined();const s=groupIntoTurns(e).find(e=>"assistant"===e.role);expect(s?.files).toBeUndefined()}),it("does not reorder when all seqs are already ascending (no-op path)",()=>{const t=parseGatewayHistory({messages:e}).map(e=>e._seq);expect(t).toEqual([1,2,3,4])})});
1
+ import{describe,expect,it,vi,beforeEach,afterEach}from"vitest";import{mkdtempSync,mkdirSync,rmSync,writeFileSync}from"node:fs";import os from"node:os";import path from"node:path";import{groupIntoTurns,loadHistory,parseGatewayHistory}from"./history-replay.js";import{LocalSessionHistory}from"./local-session-history.js";function makeGateway(e={}){const{connected:t=!0,delayMs:s=0,response:a={messages:[]},reject:o=!1}=e;return{get isConnected(){return t},chatHistory:vi.fn(async()=>{if(s>0&&await new Promise(e=>setTimeout(e,s)),o)throw new Error("rpc-failed");return a})}}describe("loadHistory",()=>{const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn()};let t;function s(e,s,a){const o=path.join(t,"agents",e,"sessions");mkdirSync(o,{recursive:!0});const n=path.join(o,`${s}.jsonl`),r=a.map(e=>JSON.stringify({id:e.id,type:"message",timestamp:(new Date).toISOString(),message:{role:e.role,content:[{type:"text",text:e.text}],timestamp:Date.now()}}));writeFileSync(n,r.join("\n")+"\n")}beforeEach(()=>{t=mkdtempSync(path.join(os.tmpdir(),"beeos-claw-lh-")),e.info.mockClear(),e.warn.mockClear(),e.debug.mockClear()}),afterEach(()=>{rmSync(t,{recursive:!0,force:!0})}),it("returns gateway messages when chat.history has content",async()=>{const t=makeGateway({response:{messages:[{id:"m1",role:"user",content:[{type:"text",text:"hi"}]},{id:"m2",role:"assistant",content:[{type:"text",text:"hello"}]}]}}),s=vi.fn(),a=await loadHistory(t,"agent:main:s1",100,{logger:e,emitObs:s});expect(a.length).toBeGreaterThan(0),expect(t.chatHistory).toHaveBeenCalledOnce(),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("source=chat.history")),expect(s).toHaveBeenCalledOnce(),expect(s).toHaveBeenCalledWith(expect.objectContaining({name:"history.source",severity:"info",payload:expect.objectContaining({source:"chat.history",messages:a.length})}))}),it("hydrates chat.history with local persisted toolResult file artifacts",async()=>{const s={status:"completed",result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"device-screenshots/screenshot.jpeg",mimeType:"image/jpeg"}]};!function(e,s,a){const o=path.join(t,"agents",e,"sessions");mkdirSync(o,{recursive:!0});const n=path.join(o,`${s}.jsonl`);writeFileSync(n,a.map(e=>JSON.stringify(e)).join("\n")+"\n")}("main","s1",[{id:"u1",type:"message",timestamp:"2026-07-02T18:02:44.475Z",message:{role:"user",content:[{type:"text",text:"@device /screenshot"}],timestamp:1783015364475}},{id:"a1",type:"message",timestamp:"2026-07-02T18:02:56.660Z",message:{role:"assistant",content:[{type:"toolCall",id:"tc-get-task",name:"beeos_get_task",arguments:{taskId:"task-1",agentId:"device"}}],timestamp:1783015376660}},{id:"tr1",type:"message",timestamp:"2026-07-02T18:02:57.046Z",message:{role:"toolResult",toolCallId:"tc-get-task",toolName:"beeos_get_task",content:[{type:"text",text:JSON.stringify({content:JSON.stringify(s)})}],details:{content:JSON.stringify(s)},isError:!1,timestamp:1783015377046}},{id:"a2",type:"message",timestamp:"2026-07-02T18:03:01.926Z",message:{role:"assistant",content:[{type:"text",text:"Done"}],timestamp:1783015381926}}]);const a=makeGateway({response:{messages:[{role:"user",content:[{type:"text",text:"@device /screenshot"}],timestamp:1783015364475,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[{type:"toolCall",id:"tc-get-task",name:"beeos_get_task",arguments:{taskId:"task-1",agentId:"device"}}],timestamp:1783015376660,__openclaw:{id:"a1",seq:2}},{role:"assistant",content:[{type:"text",text:"Done"}],timestamp:1783015381926,__openclaw:{id:"a2",seq:4}}]}}),o=new LocalSessionHistory({logger:e,stateDir:t}),n=vi.fn(),r=(await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1",emitObs:n})).find(e=>"tool_call_update"===e.updateType&&"tc-get-task"===e.toolCallId);expect(r).toMatchObject({role:"assistant",updateType:"tool_call_update",toolCallId:"tc-get-task",toolName:"beeos_get_task",files:[{uri:"beeos-file://file-1",name:"device-screenshots/screenshot.jpeg",mimeType:"image/jpeg"}]}),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("source=chat.history+local-jsonl-artifacts")),expect(n).toHaveBeenCalledWith(expect.objectContaining({name:"history.source",severity:"info",payload:expect.objectContaining({source:"chat.history+local-jsonl-artifacts",addedFileArtifacts:1})}))}),it("falls back to local jsonl when gateway is disconnected",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"},{id:"m2",role:"assistant",text:"hello"}]);const a=makeGateway({connected:!1}),o=new LocalSessionHistory({logger:e,stateDir:t}),n=vi.fn(),r=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1",emitObs:n});expect(r.length).toBeGreaterThan(0),expect(a.chatHistory).not.toHaveBeenCalled(),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("source=local-jsonl")),expect(n).toHaveBeenCalledWith(expect.objectContaining({name:"history.local_fallback",severity:"info",payload:expect.objectContaining({reason:"gateway-disconnected"})}))}),it("falls back to local jsonl when chat.history times out",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"}]);const a=makeGateway({delayMs:500}),o=new LocalSessionHistory({logger:e,stateDir:t}),n=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1",rpcTimeoutMs:50});expect(n.length).toBeGreaterThan(0),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("reason=rpc-timeout"))}),it("falls back to local jsonl when chat.history returns empty",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"}]);const a=makeGateway({response:{messages:[]}}),o=new LocalSessionHistory({logger:e,stateDir:t}),n=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1"});expect(n.length).toBeGreaterThan(0),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("reason=rpc-empty"))}),it("returns [] when both gateway and local are empty",async()=>{const s=makeGateway({response:{messages:[]}}),a=new LocalSessionHistory({logger:e,stateDir:t}),o=vi.fn(),n=await loadHistory(s,"agent:main:s1",100,{logger:e,localHistory:a,agentId:"main",sessionId:"missing",emitObs:o});expect(n).toEqual([]),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("local-empty")),expect(o).toHaveBeenCalledWith(expect.objectContaining({name:"history.none",severity:"warn",payload:expect.objectContaining({fallbackAvailable:!0})}))}),it("falls back to local on rpc error",async()=>{s("main","s1",[{id:"m1",role:"user",text:"hi"}]);const a=makeGateway({reject:!0}),o=new LocalSessionHistory({logger:e,stateDir:t}),n=await loadHistory(a,"agent:main:s1",100,{logger:e,localHistory:o,agentId:"main",sessionId:"s1"});expect(n.length).toBeGreaterThan(0),expect(e.info).toHaveBeenCalledWith(expect.stringContaining("reason=rpc-error"))})}),describe("parseGatewayHistory user envelope sanitization",()=>{it("replays production UTC same-line BeeOS user content without transport envelope",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:["Sender (untrusted metadata):","```json",'{"label":"BeeOS Platform Bridge (cli)"}',"```","[Tue 2026-07-07 12:07 UTC] User Message From BeeOS:","今天天气怎么样?"].join("\n")}],timestamp:1783426065672,__openclaw:{id:"8883d542",seq:14}}]});expect(e).toHaveLength(1),expect(e[0]).toMatchObject({role:"user",content:"今天天气怎么样?",messageId:"8883d542",_seq:14}),expect(e[0].content).not.toContain("Sender (untrusted metadata)"),expect(e[0].content).not.toContain("User Message From BeeOS"),expect(e[0].content).not.toContain("UTC]")}),it("does not sanitize assistant text that discusses BeeOS markers",()=>{const e="请解释 User Message From BeeOS: 这个字符串",t=parseGatewayHistory({messages:[{role:"assistant",content:[{type:"text",text:e}],__openclaw:{id:"a1",seq:1}}]});expect(t).toHaveLength(1),expect(t[0].content).toBe(e)})}),describe("parseGatewayHistory provider error replay",()=>{it("converts assistant stopReason=error + errorMessage (HTML) into a summarised bubble",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"你好"}],timestamp:17767039e5,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html><html><head><title>Bad gateway</title></head><body><h1>Bad gateway</h1><p>Cloudflare</p></body></html>",timestamp:1776703955154,__openclaw:{id:"a1",seq:2}}]});expect(e).toHaveLength(2);const t=e[1];expect(t.role).toBe("assistant"),expect(t.content).toContain("HTTP 502"),expect(t.content).not.toContain("<!DOCTYPE"),expect(t.content).not.toContain("<html"),expect(t.metadata).toMatchObject({status:"error",errorSource:"provider",errorCategory:"gateway",httpStatus:502,stopReason:"error"}),expect(t.metadata?.rawError).toContain("<!DOCTYPE"),expect(t.messageId).toBe("a1")}),it("passes through short plain-text error messages verbatim",()=>{const e=parseGatewayHistory({messages:[{role:"assistant",content:[],stopReason:"error",errorMessage:"rate_limit_exceeded: please slow down",timestamp:1776703955154,__openclaw:{id:"a2",seq:1}}]});expect(e).toHaveLength(1),expect(e[0].content).toBe("rate_limit_exceeded: please slow down"),expect(e[0].metadata).toMatchObject({status:"error",errorSource:"provider",errorCategory:"rate_limit"})}),it("truncates oversized plain-text error bodies",()=>{const e="x".repeat(2e3),t=parseGatewayHistory({messages:[{role:"assistant",content:[],stopReason:"error",errorMessage:e,__openclaw:{id:"a3",seq:1}}]});expect(t).toHaveLength(1),expect(t[0].content.length).toBeLessThan(e.length),expect(t[0].content.endsWith("…")).toBe(!0)}),it("does NOT reformat assistant messages whose normal content happens to contain HTML",()=>{const e="Sure, here is the snippet: <html><body><h1>Hello</h1></body></html>. The <h1> tag is a top-level heading.",t=parseGatewayHistory({messages:[{role:"assistant",content:[{type:"text",text:e}],errorMessage:"502 <html>should be ignored</html>",stopReason:"error",__openclaw:{id:"a5",seq:1}}]});expect(t).toHaveLength(1),expect(t[0].content).toBe(e),expect(t[0].content).toContain("<html>"),expect(t[0].metadata?.status).toBeUndefined(),expect(t[0].metadata?.errorCategory).toBeUndefined()}),it("collapses provider retry chains to a single terminal error entry (parentId linked)",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"hi"}],__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"u1",__openclaw:{id:"a1",seq:2}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"a1",__openclaw:{id:"a2",seq:3}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"a2",__openclaw:{id:"a3",seq:4}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 <!DOCTYPE html>bad gateway</html>",parentId:"a3",__openclaw:{id:"a4",seq:5}}]});expect(e).toHaveLength(2),expect(e[0].role).toBe("user"),expect(e[1].role).toBe("assistant"),expect(e[1].messageId).toBe("a4"),expect(e[1].content).toContain("HTTP 502");const t=e[1].content.split("HTTP 502").length-1;expect(t).toBe(1)}),it("collapse fallback: missing __openclaw metadata still keeps only the last error in a contiguous run",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"hi"}]},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 1"},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 2 final"}]});expect(e).toHaveLength(2),expect(e[1].content).toBe("502 attempt 2 final")}),it("collapses sibling-style retries where every retry's parent is the same user (not a prior error)",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"hi"}],__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 1",parentId:"u1",__openclaw:{id:"a1",seq:2}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 2",parentId:"u1",__openclaw:{id:"a2",seq:3}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 3",parentId:"u1",__openclaw:{id:"a3",seq:4}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 attempt 4 final",parentId:"u1",__openclaw:{id:"a4",seq:5}}]}).filter(e=>"error"===e.metadata?.status);expect(e).toHaveLength(1),expect(e[0].messageId).toBe("a4"),expect(e[0].metadata?.rawError).toBe("502 attempt 4 final")}),it("does NOT collapse error chains that are separated by a successful reply",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"q1"}],__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[],stopReason:"error",errorMessage:"502 first turn",parentId:"u1",__openclaw:{id:"a1",seq:2}},{role:"user",content:[{type:"text",text:"q2"}],parentId:"a1",__openclaw:{id:"u2",seq:3}},{role:"assistant",content:[],stopReason:"error",errorMessage:"503 second turn",parentId:"u2",__openclaw:{id:"a2",seq:4}}]});expect(e).toHaveLength(4),expect(e[1].role).toBe("assistant"),expect(e[1].metadata?.status).toBe("error"),expect(e[3].role).toBe("assistant"),expect(e[3].metadata?.status).toBe("error")}),it("still drops genuinely empty assistant messages (no errorMessage, no content)",()=>{const e=parseGatewayHistory({messages:[{role:"assistant",content:[],__openclaw:{id:"a4",seq:1}}]});expect(e).toHaveLength(0)})}),describe("parseGatewayHistory deterministic ordering (Bug-W)",()=>{const e=[{role:"user",content:[{type:"text",text:"hi"}],timestamp:1776704e6,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[{type:"text",text:"hello"}],timestamp:1776704001e3,__openclaw:{id:"a1",seq:2}},{role:"user",content:[{type:"text",text:"what time is it"}],timestamp:1776704002e3,__openclaw:{id:"u2",seq:3}},{role:"assistant",content:[{type:"text",text:"3pm"}],timestamp:1776704003e3,__openclaw:{id:"a2",seq:4}}];it("produces the same sequence regardless of input order (shuffled inputs)",()=>{const t=parseGatewayHistory({messages:e}),s=parseGatewayHistory({messages:[...e].reverse()}),a=parseGatewayHistory({messages:[e[2],e[0],e[3],e[1]]});expect(t).toHaveLength(4),expect(s).toEqual(t),expect(a).toEqual(t);for(let e=1;e<t.length;e++)expect(t[e]._seq).toBeGreaterThan(t[e-1]._seq)}),it("preserves original order when any entry is missing __openclaw.seq",()=>{const t=parseGatewayHistory({messages:[e[0],e[1],{role:"assistant",content:[{type:"text",text:"oops"}]},e[2],e[3]]}),s=t.findIndex(e=>"assistant"===e.role&&e.content.includes("oops")),a=t.findIndex(e=>"user"===e.role&&e.content.includes("what time"));expect(s).toBeGreaterThan(-1),expect(a).toBeGreaterThan(s)}),it("sorts within a single turn that was split into multiple JSONL rows",()=>{const e=parseGatewayHistory({messages:[{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc_1",content:"42"}],timestamp:1776704011e3,__openclaw:{id:"tr1",seq:13}},{role:"user",content:[{type:"text",text:"go"}],timestamp:1776704009e3,__openclaw:{id:"u9",seq:10}},{role:"assistant",content:[{type:"tool_use",id:"tc_1",name:"calc",input:{a:6,b:7}}],timestamp:177670401e4,__openclaw:{id:"a9",seq:12}},{role:"assistant",content:[{type:"text",text:"let me compute"}],timestamp:177670401e4,__openclaw:{id:"a9",seq:11}}]}).map(e=>e._seq);for(let t=1;t<e.length;t++)expect(e[t]).toBeGreaterThanOrEqual(e[t-1])}),it("replays persisted role=toolResult file artifacts as turn files",()=>{const e=parseGatewayHistory({messages:[{role:"user",content:[{type:"text",text:"screenshot"}],timestamp:1776704009e3,__openclaw:{id:"u1",seq:1}},{role:"assistant",content:[{type:"tool_use",id:"tc_1",name:"beeos_get_task",input:{taskId:"task-1"}}],timestamp:177670401e4,__openclaw:{id:"a1",seq:2}},{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc_1",name:"beeos_get_task",content:JSON.stringify({status:"completed",result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}],timestamp:1776704011e3,__openclaw:{id:"tr1",seq:3}},{role:"assistant",content:[{type:"text",text:"Done"}],timestamp:1776704012e3,__openclaw:{id:"a2",seq:4}}]}),t=e.find(e=>"tool_call_update"===e.updateType),s=e.findIndex(e=>"tool_call_update"===e.updateType),a=e.findIndex(e=>"assistant"===e.role&&"Done"===e.content);expect(s).toBeGreaterThanOrEqual(0),expect(a).toBeGreaterThan(s),expect(t).toMatchObject({content:"",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]});const o=groupIntoTurns(e).find(e=>"assistant"===e.role&&e.toolCalls?.length);expect(o?.files).toEqual([{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]),expect(o?.toolCalls?.[0]?.output).toBe("")}),it("does not replay text-only beeos-file URI tool results as files",()=>{const e=parseGatewayHistory({messages:[{role:"assistant",content:[{type:"tool_use",id:"tc_1",name:"beeos_get_task",input:{}}],timestamp:177670401e4,__openclaw:{id:"a1",seq:1}},{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc_1",name:"beeos_get_task",content:"beeos-file://file-1"}],timestamp:1776704011e3,__openclaw:{id:"tr1",seq:2}}]}),t=e.find(e=>"tool_call_update"===e.updateType);expect(t).toMatchObject({content:"beeos-file://file-1"}),expect(t?.files).toBeUndefined();const s=groupIntoTurns(e).find(e=>"assistant"===e.role);expect(s?.files).toBeUndefined()}),it("does not reorder when all seqs are already ascending (no-op path)",()=>{const t=parseGatewayHistory({messages:e}).map(e=>e._seq);expect(t).toEqual([1,2,3,4])})});
@@ -1 +1 @@
1
- import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";const INTERRUPTED_MESSAGE=INTERRUPTED_PUBLIC_MESSAGE;function isIncompleteToolStatus(r){return void 0===r||""===r||"in_progress"===r||"running"===r}export function getIncompleteToolCallIds(r){const e=[];for(const t of r.toolCalls??[])isIncompleteToolStatus(t.status)&&e.push(t.id);return e}function isErrorMetadata(r){return"error"===r?.status||"provider"===r?.errorSource||"openclaw:prompt-error"===r?.customType}function collectAssistantFailureText(r){const e=[];r.content&&e.push(r.content),r.metadata?.rawError&&"string"==typeof r.metadata.rawError&&e.push(r.metadata.rawError);for(const t of r.sourceMessages??[])t.content&&e.push(t.content),t.metadata?.rawError&&"string"==typeof t.metadata.rawError&&e.push(t.metadata.rawError);return e.join("\n").trim()}function classifyHistoricalRuntimeFailure(r){if("assistant"!==r.role)return;const e=collectAssistantFailureText(r),t=isErrorMetadata(r.metadata)||(r.sourceMessages??[]).some(r=>isErrorMetadata(r.metadata));if(!e&&!t)return;const o=classifyAgentRuntimeFailure({raw:e||"gateway lifecycle error",source:"history_replay",runtimeKind:"openclaw"});return"interrupted"===o.kind||t?o:void 0}export function classifyHistoricalAssistantTurnCompletion(r,e){if(e.mqActive&&e.isSkippedForLiveMq)return{kind:"live",reason:"covered_by_active_mq"};const t=getIncompleteToolCallIds(r),o=classifyHistoricalRuntimeFailure(r);return"interrupted"===o?.kind?{kind:"interrupted",stopReason:"interrupted",message:o.publicMessage,errorCode:o.errorCode,incompleteToolCallIds:t,failure:o}:"error"===o?.kind?{kind:"error",stopReason:"error",message:o.publicMessage,errorCode:o.errorCode,failure:o}:t.length>0?{kind:"interrupted",stopReason:"interrupted",message:INTERRUPTED_MESSAGE,errorCode:"orphan_tool_call",incompleteToolCallIds:t}:{kind:"completed",stopReason:"end_turn"}}
1
+ import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"../runtime/agent-runtime-failure-classifier.js";const INTERRUPTED_MESSAGE=INTERRUPTED_PUBLIC_MESSAGE;function isIncompleteToolStatus(r){return void 0===r||""===r||"in_progress"===r||"running"===r}export function getIncompleteToolCallIds(r){const e=[];for(const t of r.toolCalls??[])isIncompleteToolStatus(t.status)&&e.push(t.id);return e}function isErrorMetadata(r){return"error"===r?.status||"provider"===r?.errorSource||"openclaw:prompt-error"===r?.customType}function collectAssistantFailureText(r){const e=[];r.content&&e.push(r.content),r.metadata?.rawError&&"string"==typeof r.metadata.rawError&&e.push(r.metadata.rawError);for(const t of r.sourceMessages??[])t.content&&e.push(t.content),t.metadata?.rawError&&"string"==typeof t.metadata.rawError&&e.push(t.metadata.rawError);return e.join("\n").trim()}function classifyHistoricalRuntimeFailure(r){if("assistant"!==r.role)return;const e=collectAssistantFailureText(r),t=isErrorMetadata(r.metadata)||(r.sourceMessages??[]).some(r=>isErrorMetadata(r.metadata));if(!e&&!t)return;const o=classifyAgentRuntimeFailure({raw:e||"gateway lifecycle error",source:"history_replay",runtimeKind:"openclaw"});return"interrupted"===o.kind||t?o:void 0}export function classifyHistoricalAssistantTurnCompletion(r,e){if(e.mqActive&&e.isSkippedForLiveMq)return{kind:"live",reason:"covered_by_active_mq"};const t=getIncompleteToolCallIds(r),o=classifyHistoricalRuntimeFailure(r);return"interrupted"===o?.kind?{kind:"interrupted",stopReason:"interrupted",message:o.publicMessage,errorCode:o.errorCode,incompleteToolCallIds:t,failure:o}:"error"===o?.kind?{kind:"error",stopReason:"error",message:o.publicMessage,errorCode:o.errorCode,failure:o}:t.length>0?{kind:"interrupted",stopReason:"interrupted",message:INTERRUPTED_MESSAGE,errorCode:"orphan_tool_call",incompleteToolCallIds:t}:{kind:"completed",stopReason:"end_turn"}}
@@ -1 +1 @@
1
- import{closeSync,existsSync,openSync,readFileSync,readSync,statSync}from"node:fs";import os from"node:os";import path from"node:path";export const LOCAL_HISTORY_LIMIT=200;export const LOCAL_HISTORY_TAIL_WINDOW_LINES=6400;export const LOCAL_HISTORY_TAIL_CHUNK_BYTES=65536;export const LOCAL_HISTORY_TAIL_MAX_BYTES=1048576;export function findLocalHistoryEntryByMessageId(e,t){for(let s=e.length-1;s>=0;s--){const o=e[s];if(!o)continue;const n=o.__openclaw;if(isRecord(n)&&n.id===t)return o;if(o.id===t)return o}}export class LocalSessionHistory{logger;openclawHomeOverride;agentsDirOverride;constructor(e){this.logger=e.logger,this.openclawHomeOverride=e.openclawHome,this.agentsDirOverride=e.agentsDir}readEntries(e){const{agentId:t,sessionKey:s,sessionId:o}=e;if(!t||!s)return[];const n=this.resolveSessionsDir(t),i=this.resolveSessionFile(n,s,o);if(!i)return[];const r=this.readTailLines(i);if(0===r.length)return[];const c=[];for(const e of r){let t;try{t=JSON.parse(e)}catch{continue}if(!t||"object"!=typeof t)continue;const s=t;if("message"!==s.type)continue;const o=s.message;if(!o||"object"!=typeof o)continue;const n=o,i="string"==typeof n.role?n.role:void 0;if(!i)continue;const r="string"==typeof s.id?s.id:void 0,a=r?{id:r}:void 0,l=n.timestamp,f=s.timestamp,p="number"==typeof l||"string"==typeof l?l:"string"==typeof f?f:void 0,h={...n,role:i,...void 0!==p?{timestamp:p}:{},...a?{__openclaw:a}:{}};if(c.push(h),c.length>=200)break}return c.reverse()}resolveOpenClawHome(){if(this.openclawHomeOverride)return this.openclawHomeOverride;const e="string"==typeof process.env.OPENCLAW_HOME?process.env.OPENCLAW_HOME.trim():"";return e.length>0?e:path.join(os.homedir(),".openclaw")}resolveSessionsDir(e){return this.agentsDirOverride?path.join(this.agentsDirOverride,e,"sessions"):path.join(this.resolveOpenClawHome(),"agents",e,"sessions")}resolveSessionFile(e,t,s){const o=path.join(e,"sessions.json");let n;if(existsSync(o))try{const e=readFileSync(o,"utf-8"),t=JSON.parse(e);t&&"object"==typeof t&&(n=t)}catch(e){this.logger.warn?.(`[local-session-history] failed to read sessions.json at ${o}: ${String(e)}`)}const i=s?[t,s]:[t];let r,c=s;for(const e of i){const t=n?.[e];if(!t||"object"!=typeof t)continue;const s=t,o="string"==typeof s.sessionFile?s.sessionFile.trim():"",i="string"==typeof s.sessionId?s.sessionId.trim():"";if(o&&(r=o),i&&!c&&(c=i),r)break}if(r){const t=path.isAbsolute(r)?r:path.join(e,r);return existsSync(t)?t:void 0}if(!c)return;const a=path.join(e,`${c}.jsonl`);return existsSync(a)?a:void 0}readTailLines(e){let t,s;try{t=statSync(e).size}catch(t){return this.logger.warn?.(`[local-session-history] failed to stat ${e}: ${String(t)}`),[]}if(t<=0)return[];try{s=openSync(e,"r");let o=t,n=1048576,i="";const r=[];for(;o>0&&n>0&&r.length<6400;){const e=Math.min(65536,o,n),t=o-e,c=Buffer.allocUnsafe(e),a=readSync(s,c,0,e,t);if(a<=0)break;o=t,n-=a;const l=(c.toString("utf-8",0,a)+i).split("\n");i=l.shift()??"";for(let e=l.length-1;e>=0;e--){const t=l[e]?.trim();if(t&&(r.push(t),r.length>=6400))break}}const c=i.trim();return c&&r.length<6400&&r.push(c),r}catch(t){return this.logger.warn?.(`[local-session-history] failed to tail ${e}: ${String(t)}`),[]}finally{if(void 0!==s)try{closeSync(s)}catch{}}}}function isRecord(e){return!!e&&"object"==typeof e&&!Array.isArray(e)}
1
+ import{closeSync,existsSync,openSync,readFileSync,readSync,statSync}from"node:fs";import path from"node:path";import{resolveOpenClawStateDir}from"../runtime/openclaw-state-dir.js";export const LOCAL_HISTORY_LIMIT=200;export const LOCAL_HISTORY_TAIL_WINDOW_LINES=6400;export const LOCAL_HISTORY_TAIL_CHUNK_BYTES=65536;export const LOCAL_HISTORY_TAIL_MAX_BYTES=1048576;export function findLocalHistoryEntryByMessageId(t,e){for(let s=t.length-1;s>=0;s--){const i=t[s];if(!i)continue;const o=i.__openclaw;if(isRecord(o)&&o.id===e)return i;if(i.id===e)return i}}export class LocalSessionHistory{logger;stateDirOverride;agentsDirOverride;constructor(t){this.logger=t.logger,this.stateDirOverride=t.stateDir,this.agentsDirOverride=t.agentsDir}readEntries(t){const{agentId:e,sessionKey:s,sessionId:i}=t;if(!e||!s)return[];const o=this.resolveSessionsDir(e),r=this.resolveSessionFile(o,s,i);if(!r)return[];const n=this.readTailLines(r);if(0===n.length)return[];const c=[];for(const t of n){let e;try{e=JSON.parse(t)}catch{continue}if(!e||"object"!=typeof e)continue;const s=e;if("message"!==s.type)continue;const i=s.message;if(!i||"object"!=typeof i)continue;const o=i,r="string"==typeof o.role?o.role:void 0;if(!r)continue;const n="string"==typeof s.id?s.id:void 0,a=n?{id:n}:void 0,l=o.timestamp,f=s.timestamp,p="number"==typeof l||"string"==typeof l?l:"string"==typeof f?f:void 0,y={...o,role:r,...void 0!==p?{timestamp:p}:{},...a?{__openclaw:a}:{}};if(c.push(y),c.length>=200)break}return c.reverse()}resolveStateDir(){return this.stateDirOverride??resolveOpenClawStateDir()}resolveSessionsDir(t){return this.agentsDirOverride?path.join(this.agentsDirOverride,t,"sessions"):path.join(this.resolveStateDir(),"agents",t,"sessions")}resolveSessionFile(t,e,s){const i=path.join(t,"sessions.json");let o;if(existsSync(i))try{const t=readFileSync(i,"utf-8"),e=JSON.parse(t);e&&"object"==typeof e&&(o=e)}catch(t){this.logger.warn?.(`[local-session-history] failed to read sessions.json at ${i}: ${String(t)}`)}const r=s?[e,s]:[e];let n,c=s;for(const t of r){const e=o?.[t];if(!e||"object"!=typeof e)continue;const s=e,i="string"==typeof s.sessionFile?s.sessionFile.trim():"",r="string"==typeof s.sessionId?s.sessionId.trim():"";if(i&&(n=i),r&&!c&&(c=r),n)break}if(n){const e=path.isAbsolute(n)?n:path.join(t,n);return existsSync(e)?e:void 0}if(!c)return;const a=path.join(t,`${c}.jsonl`);return existsSync(a)?a:void 0}readTailLines(t){let e,s;try{e=statSync(t).size}catch(e){return this.logger.warn?.(`[local-session-history] failed to stat ${t}: ${String(e)}`),[]}if(e<=0)return[];try{s=openSync(t,"r");let i=e,o=1048576,r="";const n=[];for(;i>0&&o>0&&n.length<6400;){const t=Math.min(65536,i,o),e=i-t,c=Buffer.allocUnsafe(t),a=readSync(s,c,0,t,e);if(a<=0)break;i=e,o-=a;const l=(c.toString("utf-8",0,a)+r).split("\n");r=l.shift()??"";for(let t=l.length-1;t>=0;t--){const e=l[t]?.trim();if(e&&(n.push(e),n.length>=6400))break}}const c=r.trim();return c&&n.length<6400&&n.push(c),n}catch(e){return this.logger.warn?.(`[local-session-history] failed to tail ${t}: ${String(e)}`),[]}finally{if(void 0!==s)try{closeSync(s)}catch{}}}}function isRecord(t){return!!t&&"object"==typeof t&&!Array.isArray(t)}
@@ -1 +1 @@
1
- import{afterEach,beforeEach,describe,expect,it,vi}from"vitest";import{mkdtempSync,rmSync,writeFileSync,mkdirSync}from"node:fs";import os from"node:os";import path from"node:path";import{LocalSessionHistory,LOCAL_HISTORY_LIMIT,LOCAL_HISTORY_TAIL_WINDOW_LINES}from"./local-session-history.js";describe("LocalSessionHistory",()=>{let e;const s={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()};function o(s){const o=path.join(e,"agents",s,"sessions");return mkdirSync(o,{recursive:!0}),o}function t(e,s){const o=s.map(e=>JSON.stringify(e)).join("\n")+"\n";writeFileSync(e,o)}beforeEach(()=>{e=mkdtempSync(path.join(os.tmpdir(),"beeos-claw-lsh-")),s.info.mockClear(),s.warn.mockClear(),s.debug.mockClear(),s.error.mockClear()}),afterEach(()=>{rmSync(e,{recursive:!0,force:!0})}),it("returns [] when agentId or sessionKey missing",()=>{const o=new LocalSessionHistory({logger:s,openclawHome:e});expect(o.readEntries({agentId:"",sessionKey:"s"})).toEqual([]),expect(o.readEntries({agentId:"a",sessionKey:""})).toEqual([])}),it("returns [] when the jsonl file does not exist",()=>{const o=new LocalSessionHistory({logger:s,openclawHome:e});expect(o.readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"})).toEqual([])}),it("reads entries in chronological order and promotes top-level id to __openclaw.id",()=>{const n=o("main");t(path.join(n,"s1.jsonl"),[{id:"msg-001",type:"message",timestamp:"2026-04-18T00:00:00.000Z",message:{role:"user",content:"hi",timestamp:1}},{id:"msg-002",type:"message",timestamp:"2026-04-18T00:00:01.000Z",message:{role:"assistant",content:"hello",timestamp:2}},{type:"state",value:"running"}]);const i=new LocalSessionHistory({logger:s,openclawHome:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(i.length).toBe(2),expect(i[0]?.role).toBe("user"),expect(i[1]?.role).toBe("assistant"),expect(i[0].__openclaw?.id).toBe("msg-001"),expect(i[1].__openclaw?.id).toBe("msg-002")}),it("prefers the sessionFile override from sessions.json",()=>{const n=o("main");t(path.join(n,"actual-2026.jsonl"),[{id:"msg-A",type:"message",message:{role:"user",content:"override-hit",timestamp:1}}]);t(path.join(n,"s1.jsonl"),[{id:"msg-B",type:"message",message:{role:"user",content:"should-not-be-read",timestamp:1}}]),writeFileSync(path.join(n,"sessions.json"),JSON.stringify({s1:{sessionFile:"actual-2026.jsonl",sessionId:"s1"}}));const i=new LocalSessionHistory({logger:s,openclawHome:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(i.length).toBe(1),expect(i[0].__openclaw?.id).toBe("msg-A")}),it("degrades gracefully on malformed jsonl lines",()=>{const t=o("main"),n=path.join(t,"s1.jsonl");writeFileSync(n,['{"id":"msg-1","type":"message","message":{"role":"user","content":"ok","timestamp":1}}',"}not-json{",'{"id":"msg-2","type":"message","message":{"role":"assistant","content":"ok","timestamp":2}}'].join("\n")+"\n");const i=new LocalSessionHistory({logger:s,openclawHome:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(i.map(e=>e.role)).toEqual(["user","assistant"])}),it("caps output to LOCAL_HISTORY_LIMIT even when the jsonl is huge",()=>{const n=o("main"),i=path.join(n,"s1.jsonl"),a=[],r=Math.min(LOCAL_HISTORY_TAIL_WINDOW_LINES-1,3e3);for(let e=0;e<r;e++)a.push({id:`m-${e}`,type:"message",message:{role:"assistant",content:`text-${e}`,timestamp:e}});t(i,a);const m=new LocalSessionHistory({logger:s,openclawHome:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(m.length).toBe(LOCAL_HISTORY_LIMIT),expect(m[0].__openclaw?.id).toBe("m-"+(r-LOCAL_HISTORY_LIMIT)),expect(m[m.length-1].__openclaw?.id).toBe("m-"+(r-1))})});
1
+ import{afterEach,beforeEach,describe,expect,it,vi}from"vitest";import{mkdtempSync,rmSync,writeFileSync,mkdirSync}from"node:fs";import os from"node:os";import path from"node:path";import{LocalSessionHistory,LOCAL_HISTORY_LIMIT,LOCAL_HISTORY_TAIL_WINDOW_LINES}from"./local-session-history.js";describe("LocalSessionHistory",()=>{let e;const s={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()};function t(s){const t=path.join(e,"agents",s,"sessions");return mkdirSync(t,{recursive:!0}),t}function o(e,s){const t=s.map(e=>JSON.stringify(e)).join("\n")+"\n";writeFileSync(e,t)}beforeEach(()=>{e=mkdtempSync(path.join(os.tmpdir(),"beeos-claw-lsh-")),s.info.mockClear(),s.warn.mockClear(),s.debug.mockClear(),s.error.mockClear()}),afterEach(()=>{rmSync(e,{recursive:!0,force:!0})}),it("returns [] when agentId or sessionKey missing",()=>{const t=new LocalSessionHistory({logger:s,stateDir:e});expect(t.readEntries({agentId:"",sessionKey:"s"})).toEqual([]),expect(t.readEntries({agentId:"a",sessionKey:""})).toEqual([])}),it("returns [] when the jsonl file does not exist",()=>{const t=new LocalSessionHistory({logger:s,stateDir:e});expect(t.readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"})).toEqual([])}),it("reads entries in chronological order and promotes top-level id to __openclaw.id",()=>{const n=t("main");o(path.join(n,"s1.jsonl"),[{id:"msg-001",type:"message",timestamp:"2026-04-18T00:00:00.000Z",message:{role:"user",content:"hi",timestamp:1}},{id:"msg-002",type:"message",timestamp:"2026-04-18T00:00:01.000Z",message:{role:"assistant",content:"hello",timestamp:2}},{type:"state",value:"running"}]);const i=new LocalSessionHistory({logger:s,stateDir:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(i.length).toBe(2),expect(i[0]?.role).toBe("user"),expect(i[1]?.role).toBe("assistant"),expect(i[0].__openclaw?.id).toBe("msg-001"),expect(i[1].__openclaw?.id).toBe("msg-002")}),it("prefers the sessionFile override from sessions.json",()=>{const n=t("main");o(path.join(n,"actual-2026.jsonl"),[{id:"msg-A",type:"message",message:{role:"user",content:"override-hit",timestamp:1}}]);o(path.join(n,"s1.jsonl"),[{id:"msg-B",type:"message",message:{role:"user",content:"should-not-be-read",timestamp:1}}]),writeFileSync(path.join(n,"sessions.json"),JSON.stringify({s1:{sessionFile:"actual-2026.jsonl",sessionId:"s1"}}));const i=new LocalSessionHistory({logger:s,stateDir:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(i.length).toBe(1),expect(i[0].__openclaw?.id).toBe("msg-A")}),it("degrades gracefully on malformed jsonl lines",()=>{const o=t("main"),n=path.join(o,"s1.jsonl");writeFileSync(n,['{"id":"msg-1","type":"message","message":{"role":"user","content":"ok","timestamp":1}}',"}not-json{",'{"id":"msg-2","type":"message","message":{"role":"assistant","content":"ok","timestamp":2}}'].join("\n")+"\n");const i=new LocalSessionHistory({logger:s,stateDir:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(i.map(e=>e.role)).toEqual(["user","assistant"])}),it("caps output to LOCAL_HISTORY_LIMIT even when the jsonl is huge",()=>{const n=t("main"),i=path.join(n,"s1.jsonl"),a=[],r=Math.min(LOCAL_HISTORY_TAIL_WINDOW_LINES-1,3e3);for(let e=0;e<r;e++)a.push({id:`m-${e}`,type:"message",message:{role:"assistant",content:`text-${e}`,timestamp:e}});o(i,a);const m=new LocalSessionHistory({logger:s,stateDir:e}).readEntries({agentId:"main",sessionKey:"s1",sessionId:"s1"});expect(m.length).toBe(LOCAL_HISTORY_LIMIT),expect(m[0].__openclaw?.id).toBe("m-"+(r-LOCAL_HISTORY_LIMIT)),expect(m[m.length-1].__openclaw?.id).toBe("m-"+(r-1))})});
@@ -1 +1 @@
1
- const DEFAULT_MAX_QUEUE_DEPTH=20;export class SessionWriteGate{states=new Map;maxQueueDepth;now;operationId;constructor(e={}){this.maxQueueDepth=e.maxQueueDepth??20,this.now=e.now??(()=>Date.now()),this.operationId=e.operationId??(()=>`op_${Math.random().toString(36).slice(2,10)}`)}tryStartInteractive(e){const t=this.stateFor(e.sessionKey);return t.active?{ok:!1,reason:"session_busy",activeOperationId:t.active.operationId,queueDepth:t.queue.length}:{ok:!0,token:this.acquire(e.sessionKey,"interactive",t)}}async enqueueInternal(e,t){const s=this.stateFor(e.sessionKey);if(!s.active){const i=this.acquire(e.sessionKey,"internal",s);try{return await t()}finally{this.release(i)}}if(s.queue.length>=this.maxQueueDepth)throw new Error("session_queue_full");return await new Promise((e,i)=>{s.queue.push({resolve:e,reject:i,operation:t,kind:"internal"})})}release(e){if(!e)return;const t=this.states.get(e.sessionKey);t?.active&&t.active.operationId===e.operationId&&(t.active=void 0,this.drain(e.sessionKey,t))}snapshot(e){const t=this.states.get(e);return{...t?.active?{active:{...t.active}}:{},queueDepth:t?.queue.length??0}}stateFor(e){let t=this.states.get(e);return t||(t={queue:[]},this.states.set(e,t)),t}acquire(e,t,s){const i={sessionKey:e,operationId:this.operationId(),kind:t,startedAt:this.now()};return s.active=i,i}drain(e,t){const s=t.queue.shift();if(!s)return void(t.active||0!==t.queue.length||this.states.delete(e));const i=this.acquire(e,s.kind,t);Promise.resolve().then(()=>s.operation()).then(s.resolve,s.reject).finally(()=>this.release(i))}}
1
+ const DEFAULT_MAX_QUEUE_DEPTH=20,DEFAULT_QUEUE_WAIT_TIMEOUT_MS=12e4;export class SessionWriteGate{states=new Map;maxQueueDepth;queueWaitTimeoutMs;now;operationId;constructor(e={}){this.maxQueueDepth=e.maxQueueDepth??20,this.queueWaitTimeoutMs=e.queueWaitTimeoutMs??12e4,this.now=e.now??(()=>Date.now()),this.operationId=e.operationId??(()=>`op_${Math.random().toString(36).slice(2,10)}`)}tryStartInteractive(e){const t=this.stateFor(e.sessionKey);return t.active?{ok:!1,reason:"session_busy",activeOperationId:t.active.operationId,queueDepth:t.queue.length}:{ok:!0,token:this.acquire(e.sessionKey,"interactive",t)}}async enqueueInternal(e,t){const i=this.stateFor(e.sessionKey);if(!i.active){const s=this.acquire(e.sessionKey,"internal",i);try{return await t()}finally{this.release(s)}}if(i.queue.length>=this.maxQueueDepth)throw new Error("session_queue_full");return await new Promise((e,s)=>{const n={resolve:e,reject:s,operation:()=>t(),kind:"internal",releaseOnCompletion:!0};i.queue.push(n),this.armQueueTimeout(i,n)})}async acquireInternal(e){const t=this.stateFor(e.sessionKey);if(!t.active)return this.acquire(e.sessionKey,"internal",t);if(t.queue.length>=this.maxQueueDepth)throw new Error("session_queue_full");return await new Promise((e,i)=>{const s={resolve:e,reject:i,operation:e=>e,kind:"internal",releaseOnCompletion:!1};t.queue.push(s),this.armQueueTimeout(t,s)})}release(e){if(!e)return;const t=this.states.get(e.sessionKey);t?.active&&t.active.operationId===e.operationId&&(t.active=void 0,this.drain(e.sessionKey,t))}snapshot(e){const t=this.states.get(e);return{...t?.active?{active:{...t.active}}:{},queueDepth:t?.queue.length??0}}stateFor(e){let t=this.states.get(e);return t||(t={queue:[]},this.states.set(e,t)),t}acquire(e,t,i){const s={sessionKey:e,operationId:this.operationId(),kind:t,startedAt:this.now()};return i.active=s,s}drain(e,t){const i=t.queue.shift();if(!i)return void(t.active||0!==t.queue.length||this.states.delete(e));if(i.cancelled)return void this.drain(e,t);i.timer&&clearTimeout(i.timer);const s=this.acquire(e,i.kind,t);Promise.resolve().then(()=>i.operation(s)).then(i.resolve,i.reject).finally(()=>{i.releaseOnCompletion&&this.release(s)})}armQueueTimeout(e,t){t.timer=setTimeout(()=>{const i=e.queue.indexOf(t);i<0||(e.queue.splice(i,1),t.cancelled=!0,t.reject(new Error("session_queue_timeout")))},this.queueWaitTimeoutMs),t.timer.unref?.()}}
@@ -1 +1 @@
1
- import{describe,expect,it}from"vitest";import{SessionWriteGate}from"./session-write-gate.js";describe("SessionWriteGate",()=>{it("rejects concurrent interactive starts for the same session",()=>{const e=new SessionWriteGate({operationId:(()=>{let e=0;return()=>"op-"+ ++e})()}),t=e.tryStartInteractive({sessionKey:"agent:main:s1"}),s=e.tryStartInteractive({sessionKey:"agent:main:s1"});expect(t.ok).toBe(!0),expect(s).toMatchObject({ok:!1,reason:"session_busy",activeOperationId:"op-1",queueDepth:0})}),it("allows different sessions to run concurrently",()=>{const e=new SessionWriteGate;expect(e.tryStartInteractive({sessionKey:"s1"}).ok).toBe(!0),expect(e.tryStartInteractive({sessionKey:"s2"}).ok).toBe(!0)}),it("runs queued internal operations in FIFO order after release",async()=>{const e=new SessionWriteGate,t=e.tryStartInteractive({sessionKey:"s1"});if(expect(t.ok).toBe(!0),!t.ok)throw new Error("expected lock");const s=[],o=e.enqueueInternal({sessionKey:"s1"},()=>(s.push("first"),"first")),n=e.enqueueInternal({sessionKey:"s1"},()=>(s.push("second"),"second"));expect(e.snapshot("s1").queueDepth).toBe(2),e.release(t.token),await expect(o).resolves.toBe("first"),await expect(n).resolves.toBe("second"),expect(s).toEqual(["first","second"]),expect(e.snapshot("s1").queueDepth).toBe(0)})});
1
+ import{describe,expect,it}from"vitest";import{SessionWriteGate}from"./session-write-gate.js";describe("SessionWriteGate",()=>{it("rejects concurrent interactive starts for the same session",()=>{const e=new SessionWriteGate({operationId:(()=>{let e=0;return()=>"op-"+ ++e})()}),t=e.tryStartInteractive({sessionKey:"agent:main:s1"}),s=e.tryStartInteractive({sessionKey:"agent:main:s1"});expect(t.ok).toBe(!0),expect(s).toMatchObject({ok:!1,reason:"session_busy",activeOperationId:"op-1",queueDepth:0})}),it("allows different sessions to run concurrently",()=>{const e=new SessionWriteGate;expect(e.tryStartInteractive({sessionKey:"s1"}).ok).toBe(!0),expect(e.tryStartInteractive({sessionKey:"s2"}).ok).toBe(!0)}),it("runs queued internal operations in FIFO order after release",async()=>{const e=new SessionWriteGate,t=e.tryStartInteractive({sessionKey:"s1"});if(expect(t.ok).toBe(!0),!t.ok)throw new Error("expected lock");const s=[],n=e.enqueueInternal({sessionKey:"s1"},()=>(s.push("first"),"first")),o=e.enqueueInternal({sessionKey:"s1"},()=>(s.push("second"),"second"));expect(e.snapshot("s1").queueDepth).toBe(2),e.release(t.token),await expect(n).resolves.toBe("first"),await expect(o).resolves.toBe("second"),expect(s).toEqual(["first","second"]),expect(e.snapshot("s1").queueDepth).toBe(0)}),it("retains an internal lease until its runtime owner explicitly releases it",async()=>{const e=new SessionWriteGate,t=await e.acquireInternal({sessionKey:"s1"});expect(e.tryStartInteractive({sessionKey:"s1"}).ok).toBe(!1),e.release(t),expect(e.tryStartInteractive({sessionKey:"s1"}).ok).toBe(!0)}),it("bounds internal queue wait time without leaving a ghost lease",async()=>{const e=new SessionWriteGate({queueWaitTimeoutMs:5}),t=e.tryStartInteractive({sessionKey:"s1"});if(!t.ok)throw new Error("expected active lease");await expect(e.acquireInternal({sessionKey:"s1"})).rejects.toThrow("session_queue_timeout"),expect(e.snapshot("s1").queueDepth).toBe(0),e.release(t.token),expect(e.tryStartInteractive({sessionKey:"s1"}).ok).toBe(!0)})});
@@ -1 +1 @@
1
- import{homedir}from"node:os";import path from"node:path";import{readdir,readFile,rename,stat}from"node:fs/promises";const DEFAULT_STALE_MS=6e5;function parseLockOwnerPid(t){const e=t.match(/\bpid["'\s:=]+(\d+)\b/i)?.[1],s=t.trim().match(/^(\d+)$/)?.[1],n=Number(e??s);return Number.isInteger(n)&&n>0?n:void 0}function defaultPidExists(t){try{return process.kill(t,0),!0}catch(t){return"EPERM"===t.code}}async function fileExists(t){try{return await stat(t),!0}catch{return!1}}async function listSessionLockFiles(t){const e=[];let s;try{s=await readdir(t)}catch{return e}for(const n of s){const s=path.join(t,n,"sessions");let i;try{i=await readdir(s)}catch{continue}for(const t of i)t.endsWith(".jsonl.lock")&&e.push(path.join(s,t))}return e}export async function sweepStaleSessionLocks(t={}){const e=t.agentsRoot??path.join(homedir(),".openclaw","agents"),s=t.staleMs??6e5,n=t.now??(()=>Date.now()),i=t.pidExists??defaultPidExists,o=t.logger,a={scanned:0,quarantined:0,skipped:0};for(const t of await listSessionLockFiles(e)){a.scanned+=1;try{const e=await stat(t);if(n()-e.mtimeMs<s){a.skipped+=1;continue}const r=t.slice(0,-5);if(!await fileExists(r)){a.skipped+=1;continue}const c=parseLockOwnerPid(await readFile(t,"utf8").catch(()=>""));if(!c||i(c)){a.skipped+=1;continue}const d=`${t}.stale.${n()}`;await rename(t,d),a.quarantined+=1,o?.warn?.(`[stale-lock-sweeper] openclaw_stale_session_lock_quarantined lock_path=${t} quarantine_path=${d} owner_pid=${c}`)}catch(e){a.skipped+=1,o?.warn?.(`[stale-lock-sweeper] openclaw_stale_session_lock_check_failed lock_path=${t} error=${e instanceof Error?e.message:String(e)}`)}}return a}
1
+ import{createHash}from"node:crypto";import path from"node:path";import{readdir,readFile,rename,stat}from"node:fs/promises";import{resolveOpenClawStateDir}from"../runtime/openclaw-state-dir.js";const DEFAULT_STALE_MS=6e5;function parseLockOwnerPid(e){const t=e.match(/\bpid["'\s:=]+(\d+)\b/i)?.[1],s=e.trim().match(/^(\d+)$/)?.[1],a=Number(t??s);return Number.isInteger(a)&&a>0?a:void 0}function defaultPidExists(e){try{return process.kill(e,0),!0}catch(e){return"EPERM"===e.code}}function pathHash(e){return createHash("sha256").update(e).digest("hex").slice(0,16)}async function fileExists(e){try{return await stat(e),!0}catch{return!1}}async function listSessionLockFiles(e){const t=[];let s;try{s=await readdir(e)}catch{return t}for(const a of s){const s=path.join(e,a,"sessions");let n;try{n=await readdir(s)}catch{continue}for(const e of n)e.endsWith(".jsonl.lock")&&t.push(path.join(s,e))}return t}export async function sweepStaleSessionLocks(e={}){const t=e.agentsRoot??path.join(resolveOpenClawStateDir(),"agents"),s=e.staleMs??6e5,a=e.now??(()=>Date.now()),n=e.pidExists??defaultPidExists,i=e.logger,o={scanned:0,quarantined:0,skipped:0};for(const e of await listSessionLockFiles(t)){o.scanned+=1;try{const t=await stat(e);if(a()-t.mtimeMs<s){o.skipped+=1;continue}const r=e.slice(0,-5);if(!await fileExists(r)){o.skipped+=1;continue}const c=parseLockOwnerPid(await readFile(e,"utf8").catch(()=>""));if(!c||n(c)){o.skipped+=1;continue}const p=`${e}.stale.${a()}`;await rename(e,p),o.quarantined+=1,i?.warn?.(`[stale-lock-sweeper] openclaw_stale_session_lock_quarantined lock_path_hash=${pathHash(e)} owner_pid=${c}`)}catch{o.skipped+=1,i?.warn?.(`[stale-lock-sweeper] openclaw_stale_session_lock_check_failed lock_path_hash=${pathHash(e)} error_code=lock_check_failed`)}}return o}
@@ -1 +1 @@
1
- import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{sweepStaleSessionLocks}from"./acp-gateway/stale-session-lock-sweeper.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;sessionWriteGate=new SessionWriteGate;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;return s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`)),await this.sessionWriteGate.enqueueInternal({sessionKey:r,label:"agentSend"},async()=>{const s=this.gateway;if(!s?.isConnected)throw new Error("Gateway not connected");if(i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),void this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length);const a=await s.agentSend(r,e,n,t);return a?.runId&&i&&(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=a.runId,i.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(a.runId,i)),a})}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const a=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:a,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl,sessionWriteGate:this.sessionWriteGate,triggerAgentSync:()=>this.tryAgentSync()}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),interruptPrompt:(e,t)=>this.core.interruptPrompt(e,t),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,getCanvasState:()=>this.canvasOrch.canvasState,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0;const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`);try{const e=await sweepStaleSessionLocks({logger:this.logger});this.logger.info?.(`[acp-server] stale lock sweep scanned=${e.scanned} quarantined=${e.quarantined} skipped=${e.skipped}`)}catch(e){this.logger.warn?.(`[acp-server] stale lock sweep failed: ${e instanceof Error?e.message:String(e)}`)}if(s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},deliveryMode:this.config.messageBus.enabled?"push":void 0,agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}onAgentMappingUpdated(e){this.core.onAgentMappingUpdated(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentMappings(){const e=this.core.agentMapping;return e&&0!==e.size?[...e.entries()].map(([e,t])=>({localAgentId:e,platformAgent:t})):[]}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
1
+ import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{sweepStaleSessionLocks}from"./acp-gateway/stale-session-lock-sweeper.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;sessionWriteGate;sessionWriteReleaseCoordinator;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`));const a=await this.sessionWriteGate.acquireInternal({sessionKey:r,label:"agentSend"});let o=!1;try{const s=this.gateway;if(!s?.isConnected)throw new Error("Gateway not connected");if(i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length),void this.sessionWriteGate.release(a);o=!0;const c=await s.agentSend(r,e,n,t);return c?.runId&&i?(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=c.runId,i.assistantStreamStartedAt=Date.now(),i.writeGateToken=a,this.state.promptsByRunId.set(c.runId,i)):this.sessionWriteGate.release(a),c}catch(e){throw o&&this.sessionWriteReleaseCoordinator?this.sessionWriteReleaseCoordinator.releaseAfterQuiescence(a):this.sessionWriteReleaseCoordinator?this.sessionWriteReleaseCoordinator.releaseImmediately(a):this.sessionWriteGate.release(a),e}}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r,a,o){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.sessionWriteGate=a??new SessionWriteGate,this.sessionWriteReleaseCoordinator=o,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const c=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:c,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl,sessionWriteGate:this.sessionWriteGate,sessionWriteReleaseCoordinator:o,triggerAgentSync:()=>this.tryAgentSync()}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),interruptPrompt:(e,t)=>this.core.interruptPrompt(e,t),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,getCanvasState:()=>this.canvasOrch.canvasState,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0,this.sessionWriteReleaseCoordinator?.start();const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`);try{const e=await sweepStaleSessionLocks({logger:this.logger});this.logger.info?.(`[acp-server] stale lock sweep scanned=${e.scanned} quarantined=${e.quarantined} skipped=${e.skipped}`)}catch(e){this.logger.warn?.(`[acp-server] stale lock sweep failed: ${e instanceof Error?e.message:String(e)}`)}if(s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},deliveryMode:this.config.messageBus.enabled?"push":void 0,agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}onAgentMappingUpdated(e){this.core.onAgentMappingUpdated(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentMappings(){const e=this.core.agentMapping;return e&&0!==e.size?[...e.entries()].map(([e,t])=>({localAgentId:e,platformAgent:t})):[]}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.sessionWriteReleaseCoordinator?.stop(),this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
@@ -1 +1 @@
1
- import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e,conversationIdFromMessage=e=>{if("string"==typeof e.conversationId&&e.conversationId)return e.conversationId;const n=e.content??{};return"string"==typeof n.conversation_id&&n.conversation_id?n.conversation_id:"string"==typeof n.channel_id&&n.channel_id?n.channel_id:""};export const createImInboundHandler=e=>{const{runtime:n,pluginConfig:s,client:t,localServer:o,promptRunStore:a,logger:r,writeObs:i,principalIdOverride:c,promptTimeoutMs:d,dispatch:p=dispatchImInboundPrompt}=e,l=new Map;return{onChatMessage:async e=>{const m=extractChatPrompt(e,{source:"chat_message",localAgentId:o.getLocalAgentId()});if(!m)return;if(!m.channelId||!m.messageId)return void r.warn?.(`[beeos-claw] chat_message missing channelId/messageId (sessionKey=${m.sessionKey}) — drop`);const g=promptRunKey(m.channelId,m.messageId);if(l.has(g))return void r.info?.(`[beeos-claw] duplicate chat_message replay dropped (message_id=${m.messageId}, channelId=${m.channelId})`);if(l.size>=256){const e=l.keys().next().value;void 0!==e&&l.delete(e)}l.set(g,!0);const I=c||o.getDefaultPlatformAgent()?.instanceId||"";if(!I)return void r.warn?.(`[beeos-claw] chat_message dropped (no principalId resolved, sessionKey=${m.sessionKey})`);const h=(e.content??{}).metadata??void 0,_="string"==typeof h?.delivery_principal?h.delivery_principal:"";if(_&&_!==I)return void r.info?.(`[beeos-claw] chat_message skipped (delivery_principal=${_}, selfPrincipalId=${I}, message_id=${m.messageId})`);o.ensureImInboundSession(m.sessionKey,m.channelId,m.messageId);const u=resolveOrigin(h?.protocol);r.debug?.(`[beeos-claw] chat_message dispatch (sessionKey=${m.sessionKey}, channelId=${m.channelId}, origin=${u})`);const y=e=>{i({component:"plugin",domain:"im_dispatch",name:`im.dispatch.${e.type}`,severity:"dispatch_error"===e.type?"warn":"info",payload:e})};try{await p({runtime:n,pluginConfig:s,prompt:{channelId:m.channelId,inboundMessageId:m.messageId,selfPrincipalId:I,dispatchInput:{message:m.message,attachments:m.files},sessionKey:m.sessionKey},client:t,logger:r,promptRunStore:a,origin:u,promptTimeoutMs:d,onLifecycleEvent:y})}catch(e){r.error?.(`[beeos-claw] chat_message dispatch failed (channelId=${m.channelId}, in_reply_to=${m.messageId}): ${e instanceof Error?e.message:String(e)}`)}},onChatCancel:e=>{const n=e.content??{},s="string"==typeof n.target_message_id?n.target_message_id:"",t="string"==typeof n.reason?n.reason:"unknown";if(!s)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no target_message_id, reason=${t})`);const o=conversationIdFromMessage(e),i=`chat_cancel:${t}`,c=o?a.abort(promptRunKey(o,s),i):a.abortByInboundMessageId(s,i);c.ok?r.info?.(`[beeos-claw] chat_cancel aborted run (target_message_id=${s}, requestId=${c.requestId}, channelId=${c.channelId}, reason=${t})`):r.info?.(`[beeos-claw] chat_cancel no-op (target_message_id=${s}, reason=${t}, why=${c.reason})`)}}};
1
+ import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e,conversationIdFromMessage=e=>{if("string"==typeof e.conversationId&&e.conversationId)return e.conversationId;const n=e.content??{};return"string"==typeof n.conversation_id&&n.conversation_id?n.conversation_id:"string"==typeof n.channel_id&&n.channel_id?n.channel_id:""};export const createImInboundHandler=e=>{const{runtime:n,pluginConfig:s,client:o,localServer:t,promptRunStore:a,sessionWriteGate:r,sessionWriteReleaseCoordinator:i,runProcessLifecycleCoordinator:c,logger:d,writeObs:p,principalIdOverride:l,promptTimeoutMs:m,dispatch:g=dispatchImInboundPrompt}=e,I=new Map;return{onChatMessage:async e=>{const h=extractChatPrompt(e,{source:"chat_message",localAgentId:t.getLocalAgentId()});if(!h)return;if(!h.channelId||!h.messageId)return void d.warn?.(`[beeos-claw] chat_message missing channelId/messageId (sessionKey=${h.sessionKey}) — drop`);const _=promptRunKey(h.channelId,h.messageId);if(I.has(_))return void d.info?.(`[beeos-claw] duplicate chat_message replay dropped (message_id=${h.messageId}, channelId=${h.channelId})`);if(I.size>=256){const e=I.keys().next().value;void 0!==e&&I.delete(e)}I.set(_,!0);const u=l||t.getDefaultPlatformAgent()?.instanceId||"";if(!u)return void d.warn?.(`[beeos-claw] chat_message dropped (no principalId resolved, sessionKey=${h.sessionKey})`);const y=(e.content??{}).metadata??void 0,f="string"==typeof y?.delivery_principal?y.delivery_principal:"";if(f&&f!==u)return void d.info?.(`[beeos-claw] chat_message skipped (delivery_principal=${f}, selfPrincipalId=${u}, message_id=${h.messageId})`);t.ensureImInboundSession(h.sessionKey,h.channelId,h.messageId);const v=resolveOrigin(y?.protocol);d.debug?.(`[beeos-claw] chat_message dispatch (sessionKey=${h.sessionKey}, channelId=${h.channelId}, origin=${v})`);const $=e=>{p({component:"plugin",domain:"im_dispatch",name:`im.dispatch.${e.type}`,severity:"dispatch_error"===e.type?"warn":"info",payload:e})};try{await g({runtime:n,pluginConfig:s,prompt:{channelId:h.channelId,inboundMessageId:h.messageId,selfPrincipalId:u,dispatchInput:{message:h.message,attachments:h.files},sessionKey:h.sessionKey},client:o,logger:d,promptRunStore:a,sessionWriteGate:r,sessionWriteReleaseCoordinator:i,runProcessLifecycleCoordinator:c,origin:v,promptTimeoutMs:m,onLifecycleEvent:$})}catch(e){d.error?.(`[beeos-claw] chat_message dispatch failed channelId=${h.channelId} in_reply_to=${h.messageId} error_code=runtime_dispatch_failed`)}},onChatCancel:e=>{const n=e.content??{},s="string"==typeof n.target_message_id?n.target_message_id:"",o="string"==typeof n.reason?n.reason:"unknown";if(!s)return void d.warn?.(`[beeos-claw] chat_cancel dropped (no target_message_id, reason=${o})`);const t=conversationIdFromMessage(e),r=`chat_cancel:${o}`,i=t?a.abort(promptRunKey(t,s),r):a.abortByInboundMessageId(s,r);i.ok?d.info?.(`[beeos-claw] chat_cancel aborted run (target_message_id=${s}, requestId=${i.requestId}, channelId=${i.channelId}, reason=${o})`):d.info?.(`[beeos-claw] chat_cancel no-op (target_message_id=${s}, reason=${o}, why=${i.reason})`)}}};
@@ -1 +1 @@
1
- import{describe,it,expect,beforeEach,vi}from"vitest";import{createImInboundHandler}from"./im-inbound-handler.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";const fakeRuntime={channel:{reply:{dispatchReplyFromConfig:async()=>({counts:{tool:0,block:0,final:0}})}}},buildChatMessage=(e={})=>{const t=e.channelId??"chan_test";return{id:e.messageId??"msg_test",type:"chat_message",sender:e.sender??"user_caller",content:{text:e.text??"hello agent",message:e.text??"hello agent",metadata:{...e.protocol?{protocol:e.protocol}:{},...e.deliveryPrincipal?{delivery_principal:e.deliveryPrincipal}:{},context_id:e.contextId??"ctx_test"},channel_id:t}}},buildChatCancel=(e={})=>{const t={reason:e.reason??"canceled"};return e.omitTarget||(t.target_message_id=e.targetMessageId??"msg_target"),{id:"cancel_evt",type:"chat_cancel",...e.omitChannel?{}:{conversationId:e.channelId??"chan_test"},sender:"user_caller",content:t}},makeLogger=()=>{const e=[],t=[],a=[],n=[];return{warns:e,errors:t,infos:a,debugs:n,warn:t=>{e.push(t)},error:e=>{t.push(e)},info:e=>{a.push(e)},debug:e=>{n.push(e)}}},makeLocalServer=(e={})=>{const t=[];return{calls:t,getLocalAgentId:()=>e.localAgentId??"agent_local",getDefaultPlatformAgent:()=>e.noDefaultAgent?void 0:{instanceId:e.defaultInstanceId??"instance_self"},ensureImInboundSession:(e,a,n)=>{t.push({sessionKey:e,channelId:a,inboundMessageId:n})}}},fakeClient={};describe("createImInboundHandler",()=>{let e,t,a;beforeEach(()=>{e=new PromptRunStore,t=[],a=async e=>(t.push(e),!0)}),it("happy path: dispatches with correct prompt + origin=im + stamps cron-backfill session BEFORE dispatch",async()=>{const n=makeLocalServer(),r=makeLogger(),o=vi.fn(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:o,dispatch:a}),i=buildChatMessage({channelId:"chan_h1",messageId:"msg_h1"});await s.onChatMessage(i),expect(t).toHaveLength(1);const c=t[0];expect(c.prompt.channelId).toBe("chan_h1"),expect(c.prompt.inboundMessageId).toBe("msg_h1"),expect(c.prompt.selfPrincipalId).toBe("instance_self"),expect(c.prompt.dispatchInput.message).toBe("hello agent"),expect(c.origin).toBe("im"),expect(c.runtime).toBe(fakeRuntime),expect(c.client).toBe(fakeClient),expect(c.promptRunStore).toBe(e),expect(n.calls).toHaveLength(1);const l=n.calls[0];expect(l.channelId).toBe("chan_h1"),expect(l.inboundMessageId).toBe("msg_h1")}),it("skips chat_message addressed to another delivery_principal",async()=>{const n=makeLocalServer({defaultInstanceId:"agent_self"}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage({deliveryPrincipal:"agent_other"})),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.infos.some(e=>e.includes("delivery_principal=agent_other"))).toBe(!0)}),it.each([["a2a","a2a"],["mcp","mcp"],["invoke","invoke"],["im","im"],["webhook","webhook"],["slack-bridge","slack-bridge"],["arbitrary-future-tag","arbitrary-future-tag"]])("origin pass-through: metadata.protocol=%s → origin=%s",async(n,r)=>{const o=makeLocalServer(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:o,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await s.onChatMessage(buildChatMessage({protocol:n})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(r)}),it('origin fallback: missing metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage(buildChatMessage({})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: empty metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_empty",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:"",context_id:"ctx"},channel_id:"chan_empty"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: overlong metadata.protocol (>64 chars) → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="x".repeat(65);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: non-string metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_nonstr",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:42,context_id:"ctx"},channel_id:"chan_nonstr"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it("origin boundary: exactly 64-char metadata.protocol passes through",async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="y".repeat(64);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(o)}),it("drops chat_message when channelId is missing (cron-backfill marker not set)",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage({id:"msg_x",type:"chat_message",sender:"user",content:{text:"hi",message:"hi",metadata:{context_id:"ctx"}}}),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("missing channelId/messageId"))).toBe(!0)}),it("drops chat_message when no principalId resolvable",async()=>{const n=makeLocalServer({noDefaultAgent:!0}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage()),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("no principalId resolved"))).toBe(!0)}),it("principalIdOverride wins over getDefaultPlatformAgent",async()=>{const n=makeLocalServer({defaultInstanceId:"should_not_use"}),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),principalIdOverride:"pinned_principal",dispatch:a});await r.onChatMessage(buildChatMessage()),expect(t[0].prompt.selfPrincipalId).toBe("pinned_principal")}),it("dispatch error is swallowed + logged without crashing the handler",async()=>{const t=makeLocalServer(),a=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:a,writeObs:vi.fn(),dispatch:async()=>{throw new Error("runtime exploded")}});await expect(n.onChatMessage(buildChatMessage())).resolves.toBeUndefined(),expect(a.errors.some(e=>e.includes("dispatch failed")&&e.includes("runtime exploded"))).toBe(!0)}),it("chat_cancel: target_message_id maps to PromptRunStore.abort with reason prefix",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register(promptRunKey("chan_xyz","msg_cancel_me"),{requestId:"req_xyz",channelId:"chan_xyz",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({channelId:"chan_xyz",targetMessageId:"msg_cancel_me",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_xyz"))).toBe(!0)}),it("chat_cancel: bare target_message_id falls back when exactly one run matches",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register(promptRunKey("chan_legacy","msg_legacy_cancel"),{requestId:"req_legacy",channelId:"chan_legacy",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_legacy_cancel",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_legacy"))).toBe(!0)}),it("chat_cancel: bare target_message_id is no-op when multiple conversations match",()=>{const t=makeLogger(),n=new AbortController,r=new AbortController;e.register(promptRunKey("chan_a","msg_ambiguous_cancel"),{requestId:"req_a",channelId:"chan_a",abortController:n,startedAt:Date.now()}),e.register(promptRunKey("chan_b","msg_ambiguous_cancel"),{requestId:"req_b",channelId:"chan_b",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_ambiguous_cancel"})),expect(n.signal.aborted).toBe(!1),expect(r.signal.aborted).toBe(!1),expect(t.infos.some(e=>e.includes("why=ambiguous"))).toBe(!0)}),it("chat_cancel: drops when target_message_id missing",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitTarget:!0})),expect(t.warns.some(e=>e.includes("chat_cancel dropped"))).toBe(!0)}),it("chat_cancel: no-op when no matching run is registered",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({targetMessageId:"msg_unknown"})),expect(t.infos.some(e=>e.includes("chat_cancel no-op")&&e.includes("not_found"))).toBe(!0)}),it("replay dedup: same message_id delivered twice dispatches only once",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a}),s=buildChatMessage({channelId:"chan_dup",messageId:"msg_dup"});await o.onChatMessage(s),await o.onChatMessage(s),expect(t).toHaveLength(1),expect(r.infos.some(e=>e.includes("duplicate chat_message replay dropped")&&e.includes("msg_dup"))).toBe(!0)}),it("replay dedup: distinct message_ids both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_a"})),await n.onChatMessage(buildChatMessage({messageId:"msg_b"})),expect(t).toHaveLength(2)}),it("replay dedup: same message_id in different conversations both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({channelId:"chan_a",messageId:"msg_same"})),await n.onChatMessage(buildChatMessage({channelId:"chan_b",messageId:"msg_same"})),expect(t).toHaveLength(2)}),it("replay dedup: a replay after a FAILED dispatch is also dropped (stream already terminal-written by the dispatch catch path)",async()=>{const t=makeLogger();let a=0;const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:async()=>{throw a+=1,new Error("runtime exploded")}}),r=buildChatMessage({messageId:"msg_fail_replay"});await n.onChatMessage(r),await n.onChatMessage(r),expect(a).toBe(1),expect(t.infos.some(e=>e.includes("duplicate chat_message replay dropped"))).toBe(!0)}),it("replay dedup: LRU evicts oldest beyond 256 entries (insertion order)",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"}));for(let e=1;e<=256;e++)await n.onChatMessage(buildChatMessage({messageId:`msg_evict_${e}`}));await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"})),expect(t).toHaveLength(258)})});
1
+ import{describe,it,expect,beforeEach,vi}from"vitest";import{createImInboundHandler as createImInboundHandlerImpl}from"./im-inbound-handler.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";const createImInboundHandler=e=>{const t=e.sessionWriteGate??new SessionWriteGate,a=e.sessionWriteReleaseCoordinator??new SessionWriteGateReleaseCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return createImInboundHandlerImpl({...e,sessionWriteGate:t,sessionWriteReleaseCoordinator:a})},fakeRuntime={channel:{reply:{dispatchReplyFromConfig:async()=>({counts:{tool:0,block:0,final:0}})}}},buildChatMessage=(e={})=>{const t=e.channelId??"chan_test";return{id:e.messageId??"msg_test",type:"chat_message",sender:e.sender??"user_caller",content:{text:e.text??"hello agent",message:e.text??"hello agent",metadata:{...e.protocol?{protocol:e.protocol}:{},...e.deliveryPrincipal?{delivery_principal:e.deliveryPrincipal}:{},context_id:e.contextId??"ctx_test"},channel_id:t}}},buildChatCancel=(e={})=>{const t={reason:e.reason??"canceled"};return e.omitTarget||(t.target_message_id=e.targetMessageId??"msg_target"),{id:"cancel_evt",type:"chat_cancel",...e.omitChannel?{}:{conversationId:e.channelId??"chan_test"},sender:"user_caller",content:t}},makeLogger=()=>{const e=[],t=[],a=[],n=[];return{warns:e,errors:t,infos:a,debugs:n,warn:t=>{e.push(t)},error:e=>{t.push(e)},info:e=>{a.push(e)},debug:e=>{n.push(e)}}},makeLocalServer=(e={})=>{const t=[];return{calls:t,getLocalAgentId:()=>e.localAgentId??"agent_local",getDefaultPlatformAgent:()=>e.noDefaultAgent?void 0:{instanceId:e.defaultInstanceId??"instance_self"},ensureImInboundSession:(e,a,n)=>{t.push({sessionKey:e,channelId:a,inboundMessageId:n})}}},fakeClient={};describe("createImInboundHandler",()=>{let e,t,a;beforeEach(()=>{e=new PromptRunStore,t=[],a=async e=>(t.push(e),!0)}),it("happy path: dispatches with correct prompt + origin=im + stamps cron-backfill session BEFORE dispatch",async()=>{const n=makeLocalServer(),r=makeLogger(),o=vi.fn(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:o,dispatch:a}),i=buildChatMessage({channelId:"chan_h1",messageId:"msg_h1"});await s.onChatMessage(i),expect(t).toHaveLength(1);const c=t[0];expect(c.prompt.channelId).toBe("chan_h1"),expect(c.prompt.inboundMessageId).toBe("msg_h1"),expect(c.prompt.selfPrincipalId).toBe("instance_self"),expect(c.prompt.dispatchInput.message).toBe("hello agent"),expect(c.origin).toBe("im"),expect(c.runtime).toBe(fakeRuntime),expect(c.client).toBe(fakeClient),expect(c.promptRunStore).toBe(e),expect(n.calls).toHaveLength(1);const l=n.calls[0];expect(l.channelId).toBe("chan_h1"),expect(l.inboundMessageId).toBe("msg_h1")}),it("skips chat_message addressed to another delivery_principal",async()=>{const n=makeLocalServer({defaultInstanceId:"agent_self"}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage({deliveryPrincipal:"agent_other"})),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.infos.some(e=>e.includes("delivery_principal=agent_other"))).toBe(!0)}),it.each([["a2a","a2a"],["mcp","mcp"],["invoke","invoke"],["im","im"],["webhook","webhook"],["slack-bridge","slack-bridge"],["arbitrary-future-tag","arbitrary-future-tag"]])("origin pass-through: metadata.protocol=%s → origin=%s",async(n,r)=>{const o=makeLocalServer(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:o,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await s.onChatMessage(buildChatMessage({protocol:n})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(r)}),it('origin fallback: missing metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage(buildChatMessage({})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: empty metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_empty",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:"",context_id:"ctx"},channel_id:"chan_empty"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: overlong metadata.protocol (>64 chars) → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="x".repeat(65);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: non-string metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_nonstr",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:42,context_id:"ctx"},channel_id:"chan_nonstr"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it("origin boundary: exactly 64-char metadata.protocol passes through",async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="y".repeat(64);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(o)}),it("drops chat_message when channelId is missing (cron-backfill marker not set)",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage({id:"msg_x",type:"chat_message",sender:"user",content:{text:"hi",message:"hi",metadata:{context_id:"ctx"}}}),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("missing channelId/messageId"))).toBe(!0)}),it("drops chat_message when no principalId resolvable",async()=>{const n=makeLocalServer({noDefaultAgent:!0}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage()),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("no principalId resolved"))).toBe(!0)}),it("principalIdOverride wins over getDefaultPlatformAgent",async()=>{const n=makeLocalServer({defaultInstanceId:"should_not_use"}),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),principalIdOverride:"pinned_principal",dispatch:a});await r.onChatMessage(buildChatMessage()),expect(t[0].prompt.selfPrincipalId).toBe("pinned_principal")}),it("dispatch error is swallowed + logged without crashing the handler",async()=>{const t=makeLocalServer(),a=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:a,writeObs:vi.fn(),dispatch:async()=>{throw new Error("runtime exploded")}});await expect(n.onChatMessage(buildChatMessage())).resolves.toBeUndefined(),expect(a.errors.some(e=>e.includes("dispatch failed")&&e.includes("error_code=runtime_dispatch_failed")&&!e.includes("runtime exploded"))).toBe(!0)}),it("chat_cancel: target_message_id maps to PromptRunStore.abort with reason prefix",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register(promptRunKey("chan_xyz","msg_cancel_me"),{requestId:"req_xyz",channelId:"chan_xyz",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({channelId:"chan_xyz",targetMessageId:"msg_cancel_me",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_xyz"))).toBe(!0)}),it("chat_cancel: bare target_message_id falls back when exactly one run matches",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register(promptRunKey("chan_legacy","msg_legacy_cancel"),{requestId:"req_legacy",channelId:"chan_legacy",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_legacy_cancel",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_legacy"))).toBe(!0)}),it("chat_cancel: bare target_message_id is no-op when multiple conversations match",()=>{const t=makeLogger(),n=new AbortController,r=new AbortController;e.register(promptRunKey("chan_a","msg_ambiguous_cancel"),{requestId:"req_a",channelId:"chan_a",abortController:n,startedAt:Date.now()}),e.register(promptRunKey("chan_b","msg_ambiguous_cancel"),{requestId:"req_b",channelId:"chan_b",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitChannel:!0,targetMessageId:"msg_ambiguous_cancel"})),expect(n.signal.aborted).toBe(!1),expect(r.signal.aborted).toBe(!1),expect(t.infos.some(e=>e.includes("why=ambiguous"))).toBe(!0)}),it("chat_cancel: drops when target_message_id missing",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitTarget:!0})),expect(t.warns.some(e=>e.includes("chat_cancel dropped"))).toBe(!0)}),it("chat_cancel: no-op when no matching run is registered",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({targetMessageId:"msg_unknown"})),expect(t.infos.some(e=>e.includes("chat_cancel no-op")&&e.includes("not_found"))).toBe(!0)}),it("replay dedup: same message_id delivered twice dispatches only once",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a}),s=buildChatMessage({channelId:"chan_dup",messageId:"msg_dup"});await o.onChatMessage(s),await o.onChatMessage(s),expect(t).toHaveLength(1),expect(r.infos.some(e=>e.includes("duplicate chat_message replay dropped")&&e.includes("msg_dup"))).toBe(!0)}),it("replay dedup: distinct message_ids both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_a"})),await n.onChatMessage(buildChatMessage({messageId:"msg_b"})),expect(t).toHaveLength(2)}),it("replay dedup: same message_id in different conversations both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({channelId:"chan_a",messageId:"msg_same"})),await n.onChatMessage(buildChatMessage({channelId:"chan_b",messageId:"msg_same"})),expect(t).toHaveLength(2)}),it("replay dedup: a replay after a FAILED dispatch is also dropped (stream already terminal-written by the dispatch catch path)",async()=>{const t=makeLogger();let a=0;const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:async()=>{throw a+=1,new Error("runtime exploded")}}),r=buildChatMessage({messageId:"msg_fail_replay"});await n.onChatMessage(r),await n.onChatMessage(r),expect(a).toBe(1),expect(t.infos.some(e=>e.includes("duplicate chat_message replay dropped"))).toBe(!0)}),it("replay dedup: LRU evicts oldest beyond 256 entries (insertion order)",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"}));for(let e=1;e<=256;e++)await n.onChatMessage(buildChatMessage({messageId:`msg_evict_${e}`}));await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"})),expect(t).toHaveLength(258)})});
@@ -0,0 +1 @@
1
+ import{mkdirSync,mkdtempSync,rmSync,unlinkSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";import{RunProcessOwnershipRegistry}from"./runtime/run-process-ownership-registry.js";import{ProcessSessionTerminator}from"./runtime/process-session-terminator.js";import{RunProcessLifecycleCoordinator}from"./runtime/run-process-lifecycle-coordinator.js";import{createImDispatchFixture,createRuntime,deferred}from"./test-fixtures/im-runtime.fixture.js";const SESSION_LOCK_DIAGNOSTIC=["Agent failed before reply: session file locked (timeout 120000ms):","pid=357 /home/node/.openclaw/agents/main/sessions/channel-test.jsonl.lock","gatewayToken=super-secret-token"].join(" "),asOutcome=async e=>{try{return{value:await e}}catch(e){return{error:e}}};describe("dispatchImInboundPrompt Message Service session coordination",()=>{const e=[];afterEach(()=>{for(const t of e.splice(0))rmSync(t,{recursive:!0,force:!0})}),it("releases the cancelled session before a follow-up Message Service turn starts",async()=>{const e=createImDispatchFixture(),t=deferred(),s=deferred(),n=createRuntime(async({abortSignal:e,dispatcher:n})=>(t.resolve(),await new Promise((t,n)=>{e.addEventListener("abort",()=>{s.promise.then(()=>{n(new Error(String(e.reason)))})},{once:!0})}),n.sendFinalReply({text:"unreachable"}),{counts:{tool:0,block:0,final:1}})),o=dispatchImInboundPrompt(e.args(n));await t.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),s.resolve(),await expect(o).rejects.toThrow(/chat_cancel:user_request/),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"});const r=createImDispatchFixture(),a=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"follow-up completed"}),{counts:{tool:0,block:0,final:1}})),i=await dispatchImInboundPrompt(r.args(a,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-cancel",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"follow-up"}}}));expect(i).toBe(!0),expect(r.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"follow-up completed"})}),it("redacts raw OpenClaw session-lock diagnostics from the Message Service reply",async()=>{const e=createImDispatchFixture(),t=createRuntime(async()=>{throw new Error(SESSION_LOCK_DIAGNOSTIC)});await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(SESSION_LOCK_DIAGNOSTIC);const s=classifyAgentRuntimeFailure({raw:SESSION_LOCK_DIAGNOSTIC,source:"live_gateway"}).publicMessage,n=e.client.streams[0]?.terminal;expect(n).toMatchObject({state:"failed",stopReason:"error",body:s}),expect(n?.body).not.toContain(".jsonl.lock"),expect(n?.body).not.toContain("super-secret-token"),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"session_file_locked"})}),it("terminalizes a gateway-injected lock final without ever writing the diagnostic body",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:SESSION_LOCK_DIAGNOSTIC}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).rejects.toThrow(/session file locked/i),expect(e.client.streams[0]?.terminal).toMatchObject({state:"failed",stopReason:"error",body:"The previous run was interrupted before it completed."}),expect(e.client.streams[0]?.body).not.toContain(".jsonl.lock"),expect(e.client.streams[0]?.body).not.toContain("super-secret-token")}),it("binds onAgentRunStart to the active prompt owner before cancellation",async()=>{const e=createImDispatchFixture(),t=deferred(),s=createRuntime(async({replyOptions:e,abortSignal:s})=>(e.onAgentRunStart?.("openclaw-run-1"),t.resolve(),await new Promise((e,t)=>{s.addEventListener("abort",()=>{t(new Error(String(s.reason)))},{once:!0})}),{counts:{tool:0,block:0,final:0}})),n=asOutcome(dispatchImInboundPrompt(e.args(s)));await t.promise,expect(e.promptRunStore.snapshot()[0]?.[1]).toMatchObject({runtimeRunId:"openclaw-run-1",sessionKey:"agent:main:im:channel-test"}),expect(e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request")).toMatchObject({runtimeRunId:"openclaw-run-1"}),expect(await n).toHaveProperty("error")}),it("waits for exact owned-process termination before committing user cancellation",async()=>{const e=createImDispatchFixture(),t=vi.fn(async e=>{e.args.action;return{ok:!0,toolName:"process",output:{details:{status:"failed",sessionId:"process-session-90"}}}}),s=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:t})),n=deferred(),o=createRuntime(async({replyOptions:e,abortSignal:t})=>(e.onAgentRunStart?.("runtime-run-cancel"),await s.afterToolCall({runId:"runtime-run-cancel",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-90"}}}),n.resolve(),await new Promise((e,s)=>{t.addEventListener("abort",()=>{s(new Error(String(t.reason)))},{once:!0})}),{counts:{tool:1,block:0,final:0}})),r=asOutcome(dispatchImInboundPrompt(e.args(o,{runProcessLifecycleCoordinator:s})));await n.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),expect(await r).toHaveProperty("error"),expect(t.mock.calls.map(([e])=>e.args.action)).toEqual(["kill","poll"]),expect(e.client.streams[0]?.terminal).toMatchObject({state:"cancelled",stopReason:"user_stop"})}),it("does not report cancellation when owned-process termination is unverified",async()=>{const e=createImDispatchFixture(),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{details:{status:"failed"}}}:{ok:!0,output:{details:{status:"running",sessionId:"process-session-90"}}}},{verificationAttempts:1})),s=deferred(),n=createRuntime(async({replyOptions:e,abortSignal:n})=>(e.onAgentRunStart?.("runtime-run-unverified"),await t.afterToolCall({runId:"runtime-run-unverified",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-90"}}}),s.resolve(),await new Promise((e,t)=>{n.addEventListener("abort",()=>{t(new Error(String(n.reason)))},{once:!0})}),{counts:{tool:1,block:0,final:0}})),o=asOutcome(dispatchImInboundPrompt(e.args(n,{runProcessLifecycleCoordinator:t})));await s.promise,e.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),expect(await o).toHaveProperty("error"),expect(e.client.streams[0]?.terminal).toEqual({state:"failed",stopReason:"error",body:"The task could not be stopped cleanly. Please retry."}),expect(e.events.find(e=>"dispatch_error"===e.type)).toMatchObject({error:"process_termination_unverified"})}),it("does not report normal completion when owned-process cleanup is unverified",async()=>{const e=createImDispatchFixture(),t=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:async e=>"kill"===e.args.action?{ok:!0,output:{}}:{ok:!0,output:{details:{status:"running",sessionId:"process-session-yield"}}}},{verificationAttempts:1})),s=createRuntime(async({replyOptions:e,dispatcher:s})=>(e.onAgentRunStart?.("runtime-run-success-unverified"),await t.afterToolCall({runId:"runtime-run-success-unverified",sessionKey:"agent:main:im:channel-test",toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-yield"}}}),s.sendFinalReply({text:"runtime returned"}),{counts:{tool:1,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(s,{runProcessLifecycleCoordinator:t}))).resolves.toBe(!1),expect(e.client.streams[0]?.terminal).toEqual({state:"failed",stopReason:"error",body:"The task could not be stopped cleanly. Please retry."}),expect(e.events.find(e=>"dispatch_complete"===e.type)).toBeUndefined()}),it("completes normally after process polling releases naturally exited exec ownership",async()=>{const e=createImDispatchFixture(),t=vi.fn(async()=>{throw new Error("normal completion must not invoke process termination")}),s=new RunProcessLifecycleCoordinator(new RunProcessOwnershipRegistry,new ProcessSessionTerminator({invoke:t})),n=createRuntime(async({replyOptions:e,dispatcher:t})=>{const n="runtime-run-natural-completion",o="agent:main:im:channel-test";return e.onAgentRunStart?.(n),await s.afterToolCall({runId:n,sessionKey:o,toolName:"exec",params:{command:"sleep 90"},result:{details:{status:"running",sessionId:"process-session-natural"}}}),await s.afterToolCall({runId:n,sessionKey:o,toolName:"process",params:{action:"poll",sessionId:"process-session-natural"},result:{details:{status:"completed",sessionId:"process-session-natural",exitCode:0}}}),t.sendFinalReply({text:"completed naturally"}),{counts:{tool:2,block:0,final:1}}});await expect(dispatchImInboundPrompt(e.args(n,{runProcessLifecycleCoordinator:s}))).resolves.toBe(!0),expect(t).not.toHaveBeenCalled(),expect(e.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"completed naturally"})}),it("releases the gate immediately when runtime dispatch throws synchronously",async()=>{const e=new SessionWriteGate,t=new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{observationTimeoutMs:1}),s=createImDispatchFixture({sessionWriteGate:e,sessionWriteReleaseCoordinator:t}),n={channel:{reply:{dispatchReplyFromConfig:()=>{throw new Error("runtime rejected before writer start")}}}};await expect(dispatchImInboundPrompt(s.args(n))).rejects.toThrow("runtime rejected before writer start"),expect(e.snapshot("agent:main:im:channel-test").active).toBeUndefined();const o=createImDispatchFixture({sessionWriteGate:e,sessionWriteReleaseCoordinator:t}),r=createRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"accepted"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(o.args(r))).resolves.toBe(!0)}),it("returns a busy reply without invoking the runtime for a concurrent same-session envelope",async()=>{const e=new SessionWriteGate,t={sessionWriteGate:e,sessionWriteReleaseCoordinator:new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{})},s=new Set,n=deferred(),o=deferred(),r=[],a=createRuntime(async({invocation:e,abortSignal:t})=>{const a=e.ctx.SessionKey;if(r.push(a),s.has(a))throw new Error(SESSION_LOCK_DIAGNOSTIC);return s.add(a),n.resolve(),await new Promise((e,n)=>{t.addEventListener("abort",()=>{o.promise.then(()=>{s.delete(a),n(new Error(String(t.reason)))})},{once:!0})}),{counts:{tool:0,block:0,final:0}}}),i=createImDispatchFixture(t),c=dispatchImInboundPrompt(i.args(a));await n.promise;const l=createImDispatchFixture(t),p=asOutcome(dispatchImInboundPrompt(l.args(a,{prompt:{channelId:"channel-test",inboundMessageId:"message-concurrent",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"concurrent prompt"}}}))),m=await p;i.promptRunStore.abort(promptRunKey("channel-test","message-test"),"chat_cancel:user_request"),o.resolve(),await asOutcome(c),expect(m).toEqual({value:!1}),expect(r).toEqual(["agent:main:im:channel-test"]),expect(l.client.streams[0]?.terminal).toMatchObject({state:"failed",body:expect.stringMatching(/still stopping/i)})}),it("keeps a non-contended Message Service stream on the normal completion path",async()=>{const e=createImDispatchFixture(),t=createRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply?.({text:"normal"}),e.sendFinalReply({text:"normal completion"}),{counts:{tool:0,block:0,final:1}}));await expect(dispatchImInboundPrompt(e.args(t))).resolves.toBe(!0),expect(e.client.streams[0]?.terminal).toEqual({state:"completed",stopReason:"end_turn",body:"normal completion"}),expect(e.events.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"])}),it("keeps the shared gate closed until the cancelled JSONL writer releases its lock",async()=>{const t="agent:main:im:locked-conversation",s=mkdtempSync(join(tmpdir(),"beeos-im-lock-"));e.push(s);const n=join(s,"agents","main","sessions");mkdirSync(n,{recursive:!0});const o=join(n,"locked-session.jsonl"),r=`${o}.lock`;writeFileSync(o,""),writeFileSync(join(n,"sessions.json"),JSON.stringify({[t]:{sessionId:"locked-session",sessionFile:o}}));const a=new SessionWriteGate,i=new OpenClawSessionWriteQuiescence({},{stateDir:s,pollIntervalMs:5,quietConfirmationMs:5}),c={sessionWriteGate:a,sessionWriteReleaseCoordinator:new SessionWriteGateReleaseCoordinator(a,i,{},{observationTimeoutMs:100,retryDelayMs:5})},l=deferred();let p=0;const m=createRuntime(async({abortSignal:e,dispatcher:t})=>(p+=1,1===p&&(l.resolve(),await new Promise((t,s)=>{e.addEventListener("abort",()=>{s(new Error(String(e.reason)))},{once:!0})})),t.sendFinalReply({text:"after lock released"}),{counts:{tool:0,block:0,final:1}})),u=createImDispatchFixture(c),d=asOutcome(dispatchImInboundPrompt(u.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-before-cancel",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"start long run"}}})));await l.promise,writeFileSync(r,"331"),u.promptRunStore.abort(promptRunKey("channel-test","message-before-cancel"),"chat_cancel:user_request");const h=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(h.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-while-locked",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"must not enter runtime"}}}))).resolves.toBe(!1),expect(p).toBe(1),unlinkSync(r),expect(await d).toHaveProperty("error");const y=createImDispatchFixture(c);await expect(dispatchImInboundPrompt(y.args(m,{prompt:{channelId:"channel-test",inboundMessageId:"message-after-unlock",selfPrincipalId:"agent-test",sessionKey:t,dispatchInput:{message:"now safe"}}}))).resolves.toBe(!0),expect(p).toBe(2)})});
@@ -1 +1 @@
1
- import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,t,a)=>`${e}_${createHash("sha256").update(`${n}:${t}:${a}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:t,client:a,logger:r,promptRunStore:i}=e;if(!hasRuntimeReplyApi(n))return r.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${t.channelId} in_reply_to=${t.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:t.channelId,inboundMessageId:t.inboundMessageId}),!1;const s=`im_req_${randomUUID().replace(/-/g,"")}`,o=Date.now(),d=e.origin??"im",l={protocol:"im",requestId:s,sessionId:t.sessionKey??"agent:main",sourceMessageId:t.inboundMessageId,chatId:t.channelId,origin:d},c=normalizePromptTimeoutMs(e.promptTimeoutMs),u=promptRunKey(t.channelId,t.inboundMessageId),m=new AbortController;if(!i.register(u,{requestId:s,channelId:t.channelId,abortController:m,startedAt:o}))return r.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${t.inboundMessageId} requestId=${s} channel=${t.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const p=a.messages.startStream({conversationId:t.channelId,id:deterministicMessageId("asst",t.channelId,t.inboundMessageId,t.selfPrincipalId),replyTo:e.inReplyTo??t.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const a="terminal"===n?r.warn:r.debug;a?.(`[im] runtime stream ${n} PATCH error requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${e.message}`)}},{sender:t.selfPrincipalId});let I,g=!1;try{g=!0===(await p.opened()).idempotent}catch(e){r.debug?.(`[im] runtime reply stream open probe failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(g)return r.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:t.channelId,inboundMessageId:t.inboundMessageId}),i.unregister(u),!1;const h=new SerializedCallbackQueue,y=createImReplySink({stream:p,openReasoningStream:()=>(I=a.messages.startStream({conversationId:t.channelId,id:deterministicMessageId("rsn",t.channelId,t.inboundMessageId,t.selfPrincipalId),replyTo:e.inReplyTo??t.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const a="terminal"===n?r.warn:r.debug;a?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${e.message}`)}},{sender:t.selfPrincipalId}),I),abortSignal:m.signal,callbackQueue:h,logger:r,requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,origin:d}),f=async(e,n)=>{if(!I)return!1;try{return"cancel"===e?await I.cancel({stopReason:"user_stop"}):"fail"===e?await I.fail(n?{stopReason:n}:void 0):await I.finalize({stopReason:"end_turn"}),!1}catch(e){return r.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${s} channel=${t.channelId}: ${formatErrMessage(e)}`),!0}};r.info?.(`[im] runtime dispatching requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} origin=${d}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId});let b,$,M=!1;const _=async e=>{y.dispatcher.markComplete(e);try{await y.dispatcher.waitForIdle()}catch{}},w=()=>{if(!m.signal.aborted)return;const e=m.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))};try{const a=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:t.dispatchInput,replyRoute:l,replySinks:{im:y},onDispatchInvoked:()=>{M=!0}}),i=void 0!==c?Promise.race([a,new Promise((e,n)=>{b=setTimeout(()=>{$=new Error(`IM runtime dispatch timed out after ${c}ms`),r.warn?.(`[im] runtime dispatch timeout requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} timeout_ms=${c}`),m.abort($),n($)},c)})]):a,d=await i.finally(()=>{b&&(clearTimeout(b),b=void 0)});w(),await _("success"),w();const u=await f("complete");let I=!1;try{await p.finalize({stopReason:"end_turn"})}catch(e){I=!0,r.warn?.(`[im] runtime reply stream finalize failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${formatErrMessage(e)}`)}return r.info?.(`[im] runtime dispatch completed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} counts=${JSON.stringify(d.counts)} elapsed_ms=${Date.now()-o}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,counts:d.counts,...I?{terminalWriteFailed:!0}:{},...u?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await _("failure");const a=m.signal.aborted,i=a?m.signal.reason:void 0,o=a&&"string"==typeof i&&i.startsWith("chat_cancel"),d=void 0!==$;let l="runtime_error";d?l="timeout":a&&(l="abort");const c=formatErrMessage(n);o?await f("cancel"):d?await f("fail","timeout"):await f("fail");try{o?await p.cancel({stopReason:"user_stop"}):d?await p.fail({body:c,stopReason:"timeout"}):await p.fail({body:c||"execution failed"})}catch(e){r.warn?.(`[im] runtime stream terminal write failed requestId=${s} channel=${t.channelId}: ${formatErrMessage(e)}`)}throw r.warn?.(`[im] runtime dispatch failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} cause=${l} dispatchInvoked=${M} error=${c}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,error:c,cause:l}),n}finally{i.unregister(u)}};
1
+ import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.js";const PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE="The task could not be stopped cleanly. Please retry.",normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,r,t)=>`${e}_${createHash("sha256").update(`${n}:${r}:${t}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:r,client:t,logger:a,promptRunStore:i,sessionWriteGate:s,sessionWriteReleaseCoordinator:o}=e;if(!hasRuntimeReplyApi(n))return a.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),!1;const d=`im_req_${randomUUID().replace(/-/g,"")}`,c=Date.now(),l=e.origin??"im",u={protocol:"im",requestId:d,sessionId:r.sessionKey??"agent:main",sourceMessageId:r.inboundMessageId,chatId:r.channelId,origin:l},m=normalizePromptTimeoutMs(e.promptTimeoutMs),p=promptRunKey(r.channelId,r.inboundMessageId),I=r.sessionKey??"agent:main",h=new AbortController;if(!i.register(p,{requestId:d,channelId:r.channelId,abortController:h,startedAt:c,sessionKey:I}))return a.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${r.inboundMessageId} requestId=${d} channel=${r.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const g=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("asst",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId});let y,f=!1;try{f=!0===(await g.opened()).idempotent}catch(e){a.debug?.(`[im] runtime reply stream open probe failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(f)return a.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),i.unregister(p),!1;const _=new SerializedCallbackQueue;let $;const b=createImReplySink({stream:g,openReasoningStream:()=>(y=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("rsn",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId}),y),abortSignal:h.signal,callbackQueue:_,logger:a,requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,origin:l,onAgentRunStart:e=>{$=e,i.attachRuntimeRunId(p,e)}}),w=interceptAgentRuntimeFailures(b,"im_runtime",e=>a.warn?.(`[im] runtime failure reply suppressed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} error_code=${e.errorCode}`)),M=w.sink,R=async(e,n)=>{if(!y)return!1;try{return"cancel"===e?await y.cancel({stopReason:"user_stop"}):"fail"===e?await y.fail(n?{stopReason:n}:void 0):await y.finalize({stopReason:"end_turn"}),!1}catch(e){return a.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`),!0}};let E,S,q=!1;const v=async e=>{M.dispatcher.markComplete(e);try{await M.dispatcher.waitForIdle()}catch{}},k=()=>{if(!h.signal.aborted)return;const e=h.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))},C=async()=>{if(!$||!e.runProcessLifecycleCoordinator)return!0;try{const n=(await e.runProcessLifecycleCoordinator.terminateRun($)).terminations.every(e=>e.ok);return n||a.warn?.(`[im] one or more owned processes could not be terminated requestId=${d} runId=${$}`),n}catch{return a.warn?.(`[im] owned process termination failed requestId=${d} runId=${$}`),!1}},P=s.tryStartInteractive({sessionKey:I,label:"im_chat_message"});if(!P.ok){try{await g.fail({body:SESSION_BUSY_PUBLIC_MESSAGE,stopReason:"error"})}catch(e){a.warn?.(`[im] session busy terminal write failed channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.warn?.(`[im] session write gate busy sessionKey=${I} channel=${r.channelId} in_reply_to=${r.inboundMessageId} active_operation=${P.activeOperationId??"unknown"}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"session_busy",cause:"runtime_error"}),i.unregister(p),!1}const T=P.token;try{a.info?.(`[im] runtime dispatching requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} origin=${l}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId});const t=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:r.dispatchInput,replyRoute:u,replySinks:{im:M},onDispatchInvoked:()=>{q=!0}}),s=void 0!==m?Promise.race([t,new Promise((e,n)=>{E=setTimeout(()=>{S=new Error(`IM runtime dispatch timed out after ${m}ms`),a.warn?.(`[im] runtime dispatch timeout requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} timeout_ms=${m}`),h.abort(S),n(S)},m)})]):t,o=await s.finally(()=>{E&&(clearTimeout(E),E=void 0)}),I=w.detectedFailure();if(I)throw new Error(I.diagnosticMessage);if(k(),await v("success"),k(),!i.claimTerminal(p))throw k(),new Error("terminal outcome already claimed");if(!await C()){const n=await R("fail");let t=!1;try{await g.fail({body:PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE,stopReason:"error"})}catch(e){t=!0,a.warn?.(`[im] process termination failure terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}return e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"process_termination_unverified",cause:"runtime_error"}),a.warn?.(`[im] runtime dispatch terminalized after unverified process cleanup requestId=${d} channel=${r.channelId} terminalWriteFailed=${t} reasoningTerminalWriteFailed=${n}`),!1}const y=await R("complete");let f=!1;try{await g.finalize({stopReason:"end_turn"})}catch(e){f=!0,a.warn?.(`[im] runtime reply stream finalize failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.info?.(`[im] runtime dispatch completed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} counts=${JSON.stringify(o.counts)} elapsed_ms=${Date.now()-c}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,counts:o.counts,...f?{terminalWriteFailed:!0}:{},...y?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await v("failure");const t=h.signal.aborted,s=t?h.signal.reason:void 0,o=t&&"string"==typeof s&&s.startsWith("chat_cancel"),c=void 0!==S;let l="runtime_error";if(c?l="timeout":t&&(l="abort"),!i.claimTerminal(p))throw a.warn?.(`[im] terminal write skipped because another outcome already committed requestId=${d} channel=${r.channelId}`),n;const u=w.detectedFailure()??classifyAgentRuntimeFailure({raw:n,source:"im_runtime",runtimeKind:"openclaw"}),m=!await C();o&&!m?await R("cancel"):c?await R("fail","timeout"):await R("fail");try{o&&!m?await g.cancel({stopReason:"user_stop"}):o?await g.fail({body:PROCESS_TERMINATION_UNVERIFIED_PUBLIC_MESSAGE,stopReason:"error"}):await g.fail({body:u.publicMessage,stopReason:messageStopReasonForRuntimeFailure(u)})}catch(e){a.warn?.(`[im] runtime stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}throw a.warn?.(`[im] runtime dispatch failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} cause=${l} dispatchInvoked=${q} error_code=${u.errorCode}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:m?"process_termination_unverified":u.errorCode,cause:l}),n}finally{i.unregister(p),q?await o.releaseAfterQuiescence(T):o.releaseImmediately(T)}};