beeos-claw 0.5.55 → 0.5.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-gateway/assistant-mq-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-create.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core-apply-template.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-channel-reuse.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-internal-delivery.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-meta-hydration.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-timeout.test.js +1 -1
- package/dist/acp-gateway/bridge-core-history-page.test.js +1 -1
- package/dist/acp-gateway/bridge-core-persisted-tool-result.test.js +1 -1
- package/dist/acp-gateway/bridge-core-ping.test.js +1 -1
- package/dist/acp-gateway/bridge-core-session-mutation-lifecycle.test.js +1 -0
- package/dist/acp-gateway/bridge-core-session-new.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-backfill.test.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-name.test.js +1 -1
- package/dist/acp-gateway/gateway-events-file-artifact.test.js +1 -1
- package/dist/acp-gateway/gateway-events-heartbeat.test.js +1 -1
- package/dist/acp-gateway/gateway-events.js +1 -1
- package/dist/acp-gateway/session-load-inflight-user.test.js +1 -1
- package/dist/acp-gateway/session-load.test.js +1 -1
- package/dist/acp-server-agent-sync.test.js +1 -1
- package/dist/acp-server-bridge-shim.test.js +1 -1
- package/dist/acp-server.js +1 -1
- package/dist/canvas-reregister-session-race.test.js +1 -1
- package/dist/im-inbound-handler.js +1 -1
- package/dist/im-inbound-handler.test.js +1 -1
- package/dist/im-runtime-dispatch-session-guard.test.js +1 -1
- package/dist/im-runtime-dispatch.js +1 -1
- package/dist/im-runtime-dispatch.test.js +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/im-reply-sink.js +1 -1
- package/dist/runtime/im-reply-sink.test.js +1 -1
- package/dist/runtime/lifecycle-promise-observer.js +1 -0
- package/dist/runtime/lifecycle-promise-observer.test.js +1 -0
- package/dist/runtime/prompt-run-store.js +1 -1
- package/dist/runtime/prompt-run-store.test.js +1 -1
- package/dist/runtime/session-mutation-coordinator.js +1 -0
- package/dist/runtime/session-mutation-coordinator.test.js +1 -0
- package/dist/test-fixtures/im-runtime.fixture.js +1 -1
- package/dist/test-fixtures/session-mutation.fixture.js +1 -0
- package/package.json +1 -1
- package/dist/runtime/session-write-gate-release-coordinator.js +0 -1
- package/dist/runtime/session-write-gate-release-coordinator.test.js +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,s,a="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:s,state:a})}appendToolResult(e,t,s=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...s?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const s=t.replyOptions?.abortSignal;if(!s)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:s})}}}}),stubLogger=()=>{const e=[],t=t=>s=>e.push(`[${t}] ${s}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,s,a,n={})=>{const o=n.sessionWriteGate??new SessionWriteGate,r=n.sessionWriteReleaseCoordinator??new SessionWriteGateReleaseCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:s,sessionWriteGate:o,sessionWriteReleaseCoordinator:r,origin:"im",onLifecycleEvent:e=>a.push(e),...n}};describe("dispatchImInboundPrompt",()=>{let e,t,s;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,s=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const a=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(s.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const a=await dispatchImInboundPrompt(baseArgs({},e,t,s));expect(a).toBe(!1),expect(e.streams).toHaveLength(0),expect(s).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("idempotent stream open skips runtime dispatch",async()=>{e.nextOpenIdempotent=!0;let a=!1;const n=makeRuntime(async()=>(a=!0,{counts:{tool:0,block:0,final:0}})),o=await dispatchImInboundPrompt(baseArgs(n,e,t,s));expect(o).toBe(!1),expect(a).toBe(!1),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized).toBeNull(),expect(s).toEqual([{type:"skipped",reason:"idempotent_reply_exists",channelId:"ch_t1",inboundMessageId:"msg_t1"}]),expect(t.size()).toBe(0)}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const a=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=s.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{let a;const n=new Promise(e=>{a=e}),o=makeRuntime(async({abortSignal:e})=>(a(),await new Promise((t,s)=>{e.addEventListener("abort",()=>{const t=e.reason;s(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,s));await n;const i=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_request/);const c=e.streams[0];expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop");const p=s.find(e=>"dispatch_error"===e.type);expect(p).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let a;const n=new Promise(e=>{a=e}),o=makeRuntime(async({abortSignal:e})=>(a(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,s));await n;const i=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(s.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(s.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("runtime error path: stream fails with a redacted public message",async()=>{const a=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("The agent could not complete the request. Please retry.");const o=s.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const a=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let a=-1;const n=makeRuntime(async()=>(a=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,s)),expect(a).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",abortController:new AbortController,startedAt:Date.now()});const a=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=s.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const a=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(a,e,t,s)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const a=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(a,e,t,s,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const a=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const a=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let a;const n=new Promise(e=>{a=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),a(),await new Promise((e,s)=>{t.addEventListener("abort",()=>{const e=t.reason;s(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,s));await n,t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const a=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(a,e,t,s))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const a=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const a=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const s=n(t);return 2===e.streams.length&&(s.failNextTerminal=!0),s};const o=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=s.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(s.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const a=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const s=n(t);return 1===e.streams.length&&(s.failNextTerminal=!0),s};const o=await dispatchImInboundPrompt(baseArgs(a,e,t,s));expect(o).toBe(!0);const r=s.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(s.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,a,s="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:s})}appendToolResult(e,t,a=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const a=t.replyOptions?.abortSignal;if(!a)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:a})}}}}),stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,a,s,n={})=>{const o=new SessionWriteGate,r=n.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:a,sessionMutationCoordinator:r,abortTransport:n.abortTransport??{chatAbort:async()=>{}},origin:"im",onLifecycleEvent:e=>s.push(e),...n}};describe("dispatchImInboundPrompt",()=>{let e,t,a;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,a=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(a.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const s=await dispatchImInboundPrompt(baseArgs({},e,t,a));expect(s).toBe(!1),expect(e.streams).toHaveLength(0),expect(a).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("idempotent stream open skips runtime dispatch",async()=>{e.nextOpenIdempotent=!0;let s=!1;const n=makeRuntime(async()=>(s=!0,{counts:{tool:0,block:0,final:0}})),o=await dispatchImInboundPrompt(baseArgs(n,e,t,a));expect(o).toBe(!1),expect(s).toBe(!1),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized).toBeNull(),expect(a).toEqual([{type:"skipped",reason:"idempotent_reply_exists",channelId:"ch_t1",inboundMessageId:"msg_t1"}]),expect(t.size()).toBe(0)}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const s=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise((t,a)=>{e.addEventListener("abort",()=>{const t=e.reason;a(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_request/);const c=e.streams[0];expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop");const p=a.find(e=>"dispatch_error"===e.type);expect(p).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("keeps cancellation authoritative when runtime completes before chat.abort ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e}),p=vi.fn(async()=>{await c}),l=makeRuntime(async({replyOptions:e})=>(e.onAgentRunStart?.("run-cancel-race"),s(),await i,{counts:{tool:0,block:0,final:0}})),m=dispatchImInboundPrompt(baseArgs(l,e,t,a,{abortTransport:{chatAbort:p}}));await r;const d=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");n(),await Promise.resolve(),expect(e.streams[0].finalized).toBeNull(),o(),await expect(d).resolves.toMatchObject({ok:!0}),await expect(m).rejects.toThrow(/chat_cancel:user_request/),expect(p).toHaveBeenCalledWith("agent:main","run-cancel-race"),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined()}),it("keeps timeout authoritative when chat_cancel also becomes pending before abort ACK",async()=>{let s,n,o;const r=new Promise(e=>{s=e}),i=new Promise(e=>{n=e}),c=new Promise(e=>{o=e}),p=makeRuntime(async({replyOptions:e})=>(e.onAgentRunStart?.("run-timeout-race"),s(),await i,{counts:{tool:0,block:0,final:0}})),l=dispatchImInboundPrompt(baseArgs(p,e,t,a,{promptTimeoutMs:5,abortTransport:{chatAbort:async()=>{await c}}}));await r,await new Promise(e=>setTimeout(e,10));const m=t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");n(),await Promise.resolve(),o(),await expect(m).resolves.toMatchObject({ok:!0}),await expect(l).rejects.toThrow(/timed out after 5ms/),expect(e.streams[0].finalized).toMatchObject({state:"failed",stopReason:"timeout"}),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"timeout"})}),it("runtime error path: stream fails with a redacted public message",async()=>{const s=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("The agent could not complete the request. Please retry.");const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let s=-1;const n=makeRuntime(async()=>(s=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,a)),expect(s).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",requestCancel:async()=>({ok:!0}),startedAt:Date.now()});const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const s=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),s(),await new Promise((e,a)=>{t.addEventListener("abort",()=>{const e=t.reason;a(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n,await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionWriteGateReleaseCoordinator}from"./runtime/session-write-gate-release-coordinator.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{MessageServiceRecoveryScanner}from"./im-recovery-scanner.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{BEEOS_LIST_FILES_TOOL_NAME,BEEOS_LIST_FILES_TOOL_SCHEMA,BEEOS_LIST_FILES_TOOL_DESCRIPTION,executeListFiles}from"./files/list-tool.js";import{BEEOS_FETCH_FILE_TOOL_NAME,BEEOS_FETCH_FILE_TOOL_SCHEMA,BEEOS_FETCH_FILE_TOOL_DESCRIPTION,executeFetchFile}from"./files/fetch-tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{BEEOS_FILE_RESOURCE_ROUTING_RULE}from"./files/beeos-file-uri.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";import{registerArouterMediaProviders}from"./media/index.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null;const sharedSessionWriteGate=new SessionWriteGate,sharedPromptRunStore=new PromptRunStore;let sharedSessionWriteReleaseCoordinator,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:t}=await import("node:crypto"),a=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(t(null,Buffer.from(r),a))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:t,error:{code:"unhandled_rejection",message:t}})}),process.on("uncaughtException",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:t,error:{code:"uncaught_exception",message:t}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const t=o.config,a=buildThirdPartyChannelSet(r);a.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...a].join(", ")}`);const n=t.log.enabled?createObsEventWriter({dir:t.log.dir,redaction:{gatewayToken:t.gateway.token},logger:e.logger}):()=>{};n({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),n({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:t,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)n({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),t.keyFile)try{agentKeys=loadAgentKeyPair(t.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&t.agentGatewayUrl&&(setUploadDeps({gatewayUrl:t.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:t,writeObs:n,thirdPartyChannels:a,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:t,writeObs:a}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeUpload(o,t,agentKeys,n);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${s.ok}`),a({component:"upload-tool",domain:"tool",name:s.ok?"upload.success":"upload.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(s)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}}),e.registerTool({name:BEEOS_LIST_FILES_TOOL_NAME,description:BEEOS_LIST_FILES_TOOL_DESCRIPTION,parameters:BEEOS_LIST_FILES_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_list_files] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeListFiles(o,t,agentKeys,n);return a({component:"file-list-tool",domain:"tool",name:s.ok?"list_files.success":"list_files.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),s}}),e.registerTool({name:BEEOS_FETCH_FILE_TOOL_NAME,description:BEEOS_FETCH_FILE_TOOL_DESCRIPTION,parameters:BEEOS_FETCH_FILE_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_fetch_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeFetchFile(o,t,agentKeys,{signal:n,resolvePath:e.resolvePath});return a({component:"file-fetch-tool",domain:"tool",name:s.ok?"fetch_file.success":"fetch_file.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,path:s.path,size:s.size,error:s.error}}),s}})}function registerServices(e,r,o){const{cfg:t,writeObs:a,thirdPartyChannels:n}=r,s=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedSessionWriteReleaseCoordinator??=new SessionWriteGateReleaseCoordinator(sharedSessionWriteGate,new OpenClawSessionWriteQuiescence(e.logger,{agentId:t.gateway.agentId||"main"}),e.logger);const l=sharedSessionWriteReleaseCoordinator;sharedAcpServer||(sharedAcpServer=new ACPServer(t,e.logger,a,s,i,n,sharedSessionWriteGate,l));const c=sharedAcpServer;o.current=c;const g=sharedPromptRunStore;e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${t.bridge.url||"(empty)"}`),!t.bridge.url&&t.agentGatewayUrl&&s){const r=Date.now(),o=await discoverBridgeFromGW(t.agentGatewayUrl,s);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)t.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(t.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await c.start(t.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await c.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:d,url:p,principalId:u,a2aEnabled:f}=t.messageBus;let m;if(t.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);m=createTokenProvider({agentGatewayUrl:t.agentGatewayUrl,identity:e})}const y=d&&(m||t.messageBus.serviceUrl),w=y?c.getMessageClient():null;if(y&&w){w.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),w.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});let r=u||process.env.BEEOS_PRINCIPAL_ID||"";const o={serviceUrl:t.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:p,tokenProvider:m,logger:e.logger},n=new Map;let s,i=!1;const d=async()=>{if(r||!m)return r;try{const o=await m("");r=o.identityId||"",r&&e.logger.info?.("[beeos-claw] Message bus principal resolved from Agent Gateway token: %s",r)}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve message bus principal from Agent Gateway token: %s",r instanceof Error?r.message:String(r))}return r},f=()=>{if(r)return[{localAgentId:c.getLocalAgentId(),principalId:r}];const e=c.getPlatformAgentMappings().map(({localAgentId:e,platformAgent:r})=>({localAgentId:e,principalId:r.instanceId||""})).filter(e=>e.principalId);if(e.length>0)return e;const o=c.getDefaultPlatformAgent()?.instanceId||"";return o?[{localAgentId:c.getLocalAgentId(),principalId:o}]:[]},y=e=>({getLocalAgentId:()=>e.localAgentId,getDefaultPlatformAgent:()=>({instanceId:e.principalId}),ensureImInboundSession:(e,r,o)=>{c.ensureImInboundSession(e,r,o)}}),_=async(r,s)=>{if(n.has(r.principalId))return;const i=s?w:new MessageClient(o);s&&c.setMessageClient(o,r.principalId);const d=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:i,localServer:y(r),promptRunStore:g,sessionWriteGate:sharedSessionWriteGate,sessionWriteReleaseCoordinator:l,logger:e.logger,writeObs:a,principalIdOverride:r.principalId,promptTimeoutMs:t.promptTimeoutMs}),p=new MessageServiceRecoveryScanner({client:i,imInbound:d,logger:e.logger});i.on("connect",()=>{p.trigger("reconnect"),e.logger.info?.("[beeos-claw] MessageClient connected (localAgentId=%s, principalId=%s, via=%s)",r.localAgentId,r.principalId,m?"gateway":"direct")}),i.on("message",r=>{(async()=>{"chat_message"===r.type?await d.onChatMessage(r):"chat_cancel"===r.type&&d.onChatCancel(r)})().catch(r=>{e.logger.warn?.(`[beeos-claw] message handler failed: ${r instanceof Error?r.message:String(r)}`)})}),n.set(r.principalId,{client:i,scanner:p,primary:s});try{await i.connect({identityId:r.principalId}),p.start(r.principalId)}catch(e){throw n.delete(r.principalId),p.stop(),s||await i.disconnect().catch(()=>{}),e}},b=async r=>{if(i)if(s)await s;else{s=(async()=>{const o=f();if(0===o.length)return void e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled");const t=new Set(o.map(e=>e.principalId));let a=!1;for(const[o,s]of n)t.has(o)||(s.primary&&(a=!0),s.scanner.stop(),await s.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected stale identity principalId=%s reason=%s",o,r));if(a&&n.size>0)for(const[o,t]of n)t.scanner.stop(),await t.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected for primary rebalance principalId=%s reason=%s",o,r);let s=[...n.values()].some(e=>e.primary);for(const e of o){const r=!s;await _(e,r),r&&(s=!0)}})();try{await s}finally{s=void 0}}};e.registerService({id:"beeos-message-bus",async start(){if(i=!0,c.onAgentMappingUpdated(()=>{b("agent-mapping-updated").catch(r=>{e.logger.error?.("[beeos-claw] Message bus mapping sync failed:",r)})}),await d(),!r)try{await c.waitForAgentMapping()}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}await b("startup")},async stop(){i=!1;const r=[...n.values()];n.clear();for(const e of r)e.scanner.stop(),await e.client.disconnect();e.logger.info?.("[beeos-claw] MessageClient disconnected")}})}else d?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",d);if(!1!==f&&t.agentGatewayUrl){const r=e=>c.getGatewaySessionKeyForToolCall(e),o=e=>c.getMentionsForToolCall(e),a=(e,r)=>{c.broadcastNotification(e,r)},n=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:t.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},n,r,o,a,()=>c?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(t.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(t.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=t.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const a=a=>{const n=new CanvasRelayClient({canvasRelayUrl:t.canvasRelayUrl,agentKeys:agentKeys,agentId:a.id,displayName:a.displayName,avatarUrl:a.avatarUrl,tokenProvider:o,onReconnect:()=>c.onRelayReconnected(),logger:e.logger});n.connect(),r.setRelayClient(n),r.setAgentId(a.id),c.wireRelayActionHandler(),c.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${a.id}, name=${a.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await c.waitForAgentMapping();const r=c.getDefaultPlatformAgent();if(r?.id)return void a(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),c.onceAgentMappingReady(()=>{const r=c.getDefaultPlatformAgent();if(r?.id)try{a(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>a(e),isCanvasActive:e=>!!e&&c.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>c.getSessionIdForToolCall(e),getPlatformAgentId:e=>c.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>c.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>c.waitForAgentMapping()},s=new FormSchemaRegistry,i=buildCronSchemas(n,{capMs:t.cronMaxDurationCapMs});s.register(i.create),s.register(i.update);const l=new ResourceDescriptorRegistry;l.register(CRON_RESOURCE);const g=e=>async(r,o,t)=>(c.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),c.setCanvasState(r,{schemaRegistry:s,descriptorRegistry:l});const d=c.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(s),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:s,builder:d,logger:e.logger}))});const p=c.getResourceRenderer();if(p){generateActionTools(l,e,async(e,r)=>c.gatewayRequest(e,r),p,e.logger)}const u=`\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\n- ${BEEOS_FILE_RESOURCE_ROUTING_RULE}\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.`,f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return c.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&c.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?c.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const _=new TerminalManager,b=createTerminalWebSocketService({config:{port:t.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:t.terminalWsAllowedOrigins},terminalMgr:_,logger:e.logger,writeObs:a});if(t.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){b.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${t.terminalWsPort}`)},stop(){b.stop(),_.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerArouterMediaProviders(e),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
|
|
1
|
+
import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{OpenClawSessionWriteQuiescence}from"./runtime/openclaw-session-write-quiescence.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{MessageServiceRecoveryScanner}from"./im-recovery-scanner.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{BEEOS_LIST_FILES_TOOL_NAME,BEEOS_LIST_FILES_TOOL_SCHEMA,BEEOS_LIST_FILES_TOOL_DESCRIPTION,executeListFiles}from"./files/list-tool.js";import{BEEOS_FETCH_FILE_TOOL_NAME,BEEOS_FETCH_FILE_TOOL_SCHEMA,BEEOS_FETCH_FILE_TOOL_DESCRIPTION,executeFetchFile}from"./files/fetch-tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{BEEOS_FILE_RESOURCE_ROUTING_RULE}from"./files/beeos-file-uri.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";import{registerArouterMediaProviders}from"./media/index.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null;const sharedSessionWriteGate=new SessionWriteGate,sharedPromptRunStore=new PromptRunStore;let sharedSessionMutationCoordinator,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:t}=await import("node:crypto"),a=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(t(null,Buffer.from(r),a))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:t,error:{code:"unhandled_rejection",message:t}})}),process.on("uncaughtException",o=>{const t=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:t,error:{code:"uncaught_exception",message:t}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const t=o.config,a=buildThirdPartyChannelSet(r);a.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...a].join(", ")}`);const n=t.log.enabled?createObsEventWriter({dir:t.log.dir,redaction:{gatewayToken:t.gateway.token},logger:e.logger}):()=>{};n({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),n({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:t,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)n({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),t.keyFile)try{agentKeys=loadAgentKeyPair(t.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&t.agentGatewayUrl&&(setUploadDeps({gatewayUrl:t.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:t,writeObs:n,thirdPartyChannels:a,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:t,writeObs:a}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeUpload(o,t,agentKeys,n);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${s.ok}`),a({component:"upload-tool",domain:"tool",name:s.ok?"upload.success":"upload.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(s)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}}),e.registerTool({name:BEEOS_LIST_FILES_TOOL_NAME,description:BEEOS_LIST_FILES_TOOL_DESCRIPTION,parameters:BEEOS_LIST_FILES_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_list_files] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeListFiles(o,t,agentKeys,n);return a({component:"file-list-tool",domain:"tool",name:s.ok?"list_files.success":"list_files.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),s}}),e.registerTool({name:BEEOS_FETCH_FILE_TOOL_NAME,description:BEEOS_FETCH_FILE_TOOL_DESCRIPTION,parameters:BEEOS_FETCH_FILE_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_fetch_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeFetchFile(o,t,agentKeys,{signal:n,resolvePath:e.resolvePath});return a({component:"file-fetch-tool",domain:"tool",name:s.ok?"fetch_file.success":"fetch_file.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,path:s.path,size:s.size,error:s.error}}),s}})}function registerServices(e,r,o){const{cfg:t,writeObs:a,thirdPartyChannels:n}=r,s=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedSessionMutationCoordinator??=new SessionMutationCoordinator(sharedSessionWriteGate,new OpenClawSessionWriteQuiescence(e.logger,{agentId:t.gateway.agentId||"main"}),e.logger);const l=sharedSessionMutationCoordinator;sharedAcpServer||(sharedAcpServer=new ACPServer(t,e.logger,l,a,s,i,n));const c=sharedAcpServer;o.current=c;const g=sharedPromptRunStore;e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${t.bridge.url||"(empty)"}`),!t.bridge.url&&t.agentGatewayUrl&&s){const r=Date.now(),o=await discoverBridgeFromGW(t.agentGatewayUrl,s);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)t.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(t.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await c.start(t.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await c.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:d,url:p,principalId:u,a2aEnabled:f}=t.messageBus;let m;if(t.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);m=createTokenProvider({agentGatewayUrl:t.agentGatewayUrl,identity:e})}const y=d&&(m||t.messageBus.serviceUrl),w=y?c.getMessageClient():null;if(y&&w){w.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),w.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});let r=u||process.env.BEEOS_PRINCIPAL_ID||"";const o={serviceUrl:t.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:p,tokenProvider:m,logger:e.logger},n=new Map;let s,i=!1;const d=async()=>{if(r||!m)return r;try{const o=await m("");r=o.identityId||"",r&&e.logger.info?.("[beeos-claw] Message bus principal resolved from Agent Gateway token: %s",r)}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve message bus principal from Agent Gateway token: %s",r instanceof Error?r.message:String(r))}return r},f=()=>{if(r)return[{localAgentId:c.getLocalAgentId(),principalId:r}];const e=c.getPlatformAgentMappings().map(({localAgentId:e,platformAgent:r})=>({localAgentId:e,principalId:r.instanceId||""})).filter(e=>e.principalId);if(e.length>0)return e;const o=c.getDefaultPlatformAgent()?.instanceId||"";return o?[{localAgentId:c.getLocalAgentId(),principalId:o}]:[]},y=e=>({getLocalAgentId:()=>e.localAgentId,getDefaultPlatformAgent:()=>({instanceId:e.principalId}),ensureImInboundSession:(e,r,o)=>{c.ensureImInboundSession(e,r,o)},chatAbort:(e,r)=>c.chatAbort(e,r)}),b=async(r,s)=>{if(n.has(r.principalId))return;const i=s?w:new MessageClient(o);s&&c.setMessageClient(o,r.principalId);const d=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:i,localServer:y(r),promptRunStore:g,sessionMutationCoordinator:l,logger:e.logger,writeObs:a,principalIdOverride:r.principalId,promptTimeoutMs:t.promptTimeoutMs}),p=new MessageServiceRecoveryScanner({client:i,imInbound:d,logger:e.logger});i.on("connect",()=>{p.trigger("reconnect"),e.logger.info?.("[beeos-claw] MessageClient connected (localAgentId=%s, principalId=%s, via=%s)",r.localAgentId,r.principalId,m?"gateway":"direct")}),i.on("message",r=>{(async()=>{"chat_message"===r.type?await d.onChatMessage(r):"chat_cancel"===r.type&&await d.onChatCancel(r)})().catch(r=>{e.logger.warn?.(`[beeos-claw] message handler failed: ${r instanceof Error?r.message:String(r)}`)})}),n.set(r.principalId,{client:i,scanner:p,primary:s});try{await i.connect({identityId:r.principalId}),p.start(r.principalId)}catch(e){throw n.delete(r.principalId),p.stop(),s||await i.disconnect().catch(()=>{}),e}},_=async r=>{if(i)if(s)await s;else{s=(async()=>{const o=f();if(0===o.length)return void e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled");const t=new Set(o.map(e=>e.principalId));let a=!1;for(const[o,s]of n)t.has(o)||(s.primary&&(a=!0),s.scanner.stop(),await s.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected stale identity principalId=%s reason=%s",o,r));if(a&&n.size>0)for(const[o,t]of n)t.scanner.stop(),await t.client.disconnect(),n.delete(o),e.logger.info?.("[beeos-claw] MessageClient disconnected for primary rebalance principalId=%s reason=%s",o,r);let s=[...n.values()].some(e=>e.primary);for(const e of o){const r=!s;await b(e,r),r&&(s=!0)}})();try{await s}finally{s=void 0}}};e.registerService({id:"beeos-message-bus",async start(){if(i=!0,c.onAgentMappingUpdated(()=>{_("agent-mapping-updated").catch(r=>{e.logger.error?.("[beeos-claw] Message bus mapping sync failed:",r)})}),await d(),!r)try{await c.waitForAgentMapping()}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}await _("startup")},async stop(){i=!1;const r=[...n.values()];n.clear();for(const e of r)e.scanner.stop(),await e.client.disconnect();e.logger.info?.("[beeos-claw] MessageClient disconnected")}})}else d?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",d);if(!1!==f&&t.agentGatewayUrl){const r=e=>c.getGatewaySessionKeyForToolCall(e),o=e=>c.getMentionsForToolCall(e),a=(e,r)=>{c.broadcastNotification(e,r)},n=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:t.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},n,r,o,a,()=>c?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(t.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(t.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=t.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const a=a=>{const n=new CanvasRelayClient({canvasRelayUrl:t.canvasRelayUrl,agentKeys:agentKeys,agentId:a.id,displayName:a.displayName,avatarUrl:a.avatarUrl,tokenProvider:o,onReconnect:()=>c.onRelayReconnected(),logger:e.logger});n.connect(),r.setRelayClient(n),r.setAgentId(a.id),c.wireRelayActionHandler(),c.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${a.id}, name=${a.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await c.waitForAgentMapping();const r=c.getDefaultPlatformAgent();if(r?.id)return void a(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),c.onceAgentMappingReady(()=>{const r=c.getDefaultPlatformAgent();if(r?.id)try{a(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>a(e),isCanvasActive:e=>!!e&&c.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>c.getSessionIdForToolCall(e),getPlatformAgentId:e=>c.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>c.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>c.waitForAgentMapping()},s=new FormSchemaRegistry,i=buildCronSchemas(n,{capMs:t.cronMaxDurationCapMs});s.register(i.create),s.register(i.update);const l=new ResourceDescriptorRegistry;l.register(CRON_RESOURCE);const g=e=>async(r,o,t)=>(c.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),c.setCanvasState(r,{schemaRegistry:s,descriptorRegistry:l});const d=c.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(s),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:s,builder:d,logger:e.logger}))});const p=c.getResourceRenderer();if(p){generateActionTools(l,e,async(e,r)=>c.gatewayRequest(e,r),p,e.logger)}const u=`\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\n- ${BEEOS_FILE_RESOURCE_ROUTING_RULE}\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.`,f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return c.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&c.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?c.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const b=new TerminalManager,_=createTerminalWebSocketService({config:{port:t.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:t.terminalWsAllowedOrigins},terminalMgr:b,logger:e.logger,writeObs:a});if(t.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){_.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${t.terminalWsPort}`)},stop(){_.stop(),b.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerArouterMediaProviders(e),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",
|
|
1
|
+
import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,classifyAuthoritativeTerminalToolStatus=e=>{if("string"==typeof e)switch(e.trim().toLowerCase()){case"completed":return"success";case"failed":case"error":case"cancelled":return"failure";default:return}},extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",s="";const o=new Map,i=new Map,a=[],l=[];let d=0,u=0;const m=(t,n,r)=>{const s=u;return e.stream.appendToolUse(t,n,r,"streaming"),u+=1,s},g=()=>(d+=1,`tool_${e.requestId}_${d}`),c=e=>{const t=a.indexOf(e);t>=0&&a.splice(t,1)},p=e=>{const t=e?a.filter(t=>o.get(t)?.name===e):a;return 1===t.length?t[0]:void 0},f=e=>{const t=l.map((e,t)=>({hint:e,index:t})).filter(({hint:t})=>!e||t.name===e);if(1!==t.length)return;const[{hint:n,index:r}]=t;return l.splice(r,1),n},h=(e,t)=>{i.set(e,t)},y=e=>i.get(e)??e,I=e=>null!=e&&("object"!=typeof e||(Array.isArray(e)?e.length>0:Object.keys(e).length>0)),v=(t,n,r,s)=>{const i=o.get(t);if(i){const o="tool"===i.name&&"tool"!==n?n:i.name,l=!I(i.arguments)&&I(r)?r:i.arguments;return o===i.name&&l===i.arguments||(e.stream.replacePart(i.partIndex,{type:"tool_use",id:i.id,name:o,arguments:l,state:i.state}),i.name=o,i.arguments=l),s&&!a.includes(t)&&"streaming"===i.state&&a.push(t),i}const l={id:t,name:n,arguments:r,partIndex:m(t,n,r),state:"streaming",resultSignatures:new Set,itemEventObserved:!1,toolStartObserved:!1};return o.set(t,l),s&&a.push(t),l},$=t=>{"done"!==t.state&&(e.stream.replacePart(t.partIndex,{type:"tool_use",id:t.id,name:t.name,arguments:t.arguments,state:"done"}),t.state="done")},x=(t,n,r)=>{const s=`${r?"1":"0"}:${"string"==typeof n?n:JSON.stringify(n)}`;t.resultSignatures.has(s)||(((t,n,r)=>{e.stream.appendToolResult(t,n,r),u+=1})(t.id,n,r),t.resultSignatures.add(s))},b=(e,t,n)=>{const r=i.get(e);if(r)return v(r,t??"tool",n??{},!1);if(o.has(e))return v(e,t??"tool",n??{},!1);const s=p(t);return s?(h(e,s),v(s,t??"tool",n??{},!1)):v(e,t??"tool",n??{},!1)},S=(e,t)=>{const n=i.get(e);if(n)return v(n,t,{},!1);if(o.has(e))return v(e,t,{},!1);const r=(e=>{const t=a.filter(t=>{const n=o.get(t);return!(void 0===n||e&&n.name!==e||n.itemEventObserved)});return 1===t.length?t[0]:void 0})(t);if(r)return h(e,r),v(r,t,{},!1);const s=f(t);return v(e,t,s?.arguments??{},!1)},k={tool:0,block:0,final:0};let _=!1;const T=(t,n)=>{e.callbackQueue.enqueueSync(()=>{if((t=>e.abortSignal.aborted?(e.logger.debug?.(`[im-sink] ${t} ignored after abort requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):e.stream.isTerminated?(e.logger.debug?.(`[im-sink] ${t} ignored after stream_terminated requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):!_||(e.logger.debug?.(`[im-sink] ${t} ignored after mark_complete requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1))(t))try{n()}catch(n){const r=n instanceof Error?n.message:String(n);e.logger.warn?.(`[im-sink] ${t} mapping failed requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId} error=${r}`)}})};return{dispatcher:{sendToolResult:t=>(T("tool_result",()=>{const n=resolveRuntimeToolCallId(t),r=resolveRuntimeToolName(t),s=n?void 0:r?p(r):p(),i=n||s?void 0:f(r);if(!n&&!s&&!i)return void e.logger.debug?.(`[im-sink] ambiguous id-less tool result ignored requestId=${e.requestId} channel=${e.channelId} tool=${r??"unknown"} pending=${a.length}`);const l=n?b(n,r,resolveRuntimeToolArguments(t)):v(s??g(),r??o.get(s??"")?.name??i.name,I(resolveRuntimeToolArguments(t))?resolveRuntimeToolArguments(t):i?.arguments??{},!1);k.tool+=1,$(l),x(l,(e=>{const t=[e.mediaUrl,...e.mediaUrls??[]].filter(e=>isNonEmptyString(e));return isNonEmptyString(e.text)&&t.length>0?{text:e.text,media_urls:t}:isNonEmptyString(e.text)?e.text:1===t.length?t[0]:t.length>1?{media_urls:t}:!0===e.isError?"Tool execution failed.":""})(t),!0===t.isError),c(l.id)}),!0),sendBlockReply:n=>(T("block_reply",()=>{k.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(T("final_reply",()=>{k.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...k}),markComplete:t=>{if(1===l.length){const e=l.shift();v(g(),e.name,e.arguments,!1)}for(const n of o.values())try{"success"===t&&$(n),c(n.id)}catch(t){const r=t instanceof Error?t.message:String(t);e.logger.warn?.(`[im-sink] tool finalization failed requestId=${e.requestId} toolUseId=${n.id} error=${r}`)}l.length=0,_=!0}},replyOptions:{abortSignal:e.abortSignal,onAgentRunStart:t=>{e.onAgentRunStart?.(t),e.logger.info?.(`[im-sink] run_start requestId=${e.requestId} runId=${t} channel=${e.channelId} in_reply_to=${e.inboundMessageId} origin=${e.origin??"im"}`)},onPartialReply:n=>{T("partial",()=>{const r=extractBodyUpdate(t,n.text);switch(r.kind){case"append":e.stream.appendBody(r.text);break;case"replace":e.stream.setBody(r.text)}})},onReasoningStream:t=>{T("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const o=t.text;""===s&&""!==r&&(n.appendBody("\n"),r+="\n"),o!==s&&(o.startsWith(s)?(n.appendBody(o.slice(s.length)),s=o):s.startsWith(o)||(n.setBody(r+o),s=o))})},onReasoningEnd:()=>{T("reasoning_end",()=>{var t;""!==s&&(t=s,e.stream.appendThinking(t,"done"),u+=1,r+=s,s="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{T("tool_start",()=>{const n="string"==typeof t.phase?t.phase:"start",r=resolveRuntimeToolName(t)??"tool",s=resolveRuntimeToolCallId(t);if("update"===n){if(s){const e=y(s);o.has(e)&&v(e,r,resolveRuntimeToolArguments(t),!0)}return}const i=resolveRuntimeToolArguments(t);if(s){const e=b(s,r,i);return e.toolStartObserved=!0,void(a.includes(e.id)||"streaming"!==e.state||a.push(e.id))}const d=(u=r,a.map(e=>o.get(e)).filter(e=>void 0!==e&&(!u||e.name===u)&&e.itemEventObserved));var u;if(1===d.length){return void(v(d[0].id,r,i,!0).toolStartObserved=!0)}d.length>1?e.logger.debug?.(`[im-sink] ambiguous id-less tool start ignored requestId=${e.requestId} channel=${e.channelId} tool=${r} candidates=${d.length}`):l.push({name:r,arguments:i})})},onItemEvent:e=>{T("item_event",()=>{const t="string"==typeof e.kind?e.kind.toLowerCase():"";if(t&&"tool"!==t)return;const n="string"==typeof e.phase?e.phase.toLowerCase():"update",r=resolveRuntimeToolCallId(e)??("string"==typeof e.itemId&&e.itemId.trim()?e.itemId.trim():void 0),s=resolveRuntimeToolName(e)??("string"==typeof e.title&&e.title.trim()?e.title.trim():void 0);if(!r||!s)return;if("update"===n){const e=y(r),t=o.get(e);return void(t&&(v(t.id,s,{},!0).itemEventObserved=!0))}const i=S(r,s);if(i.itemEventObserved=!0,a.includes(i.id)||"streaming"!==i.state||a.push(i.id),"end"!==n)return;const l=classifyAuthoritativeTerminalToolStatus(e.status);if(!l)return;const d=isNonEmptyString(e.summary)?e.summary:isNonEmptyString(e.meta)?e.meta:"";$(i),d?x(i,d,"failure"===l):"failure"===l&&x(i,"Tool execution failed.",!0),c(i.id)})}}}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach}from"vitest";import{SerializedCallbackQueue}from"./callback-queue.js";import{createImReplySink}from"./im-reply-sink.js";class FakeMessageStream{calls=[];parts=[];body="";isTerminated=!1;appendBody(e){if(this.isTerminated)throw new Error("terminated");this.body+=e,this.calls.push({type:"appendBody",chunk:e})}setBody(e){if(this.isTerminated)throw new Error("terminated");this.body=e,this.calls.push({type:"setBody",text:e})}addPart(e){if(this.isTerminated)throw new Error("terminated");this.parts.push(e),this.calls.push({type:"addPart",part:e})}replacePart(e,t){if(this.isTerminated)throw new Error("terminated");this.parts[e]=t,this.calls.push({type:"replacePart",index:e,part:t})}appendThinking(e,t="done"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"thinking",text:e,state:t}),this.calls.push({type:"appendThinking",text:e,state:t})}appendToolUse(e,t,a,o="streaming"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:o}),this.calls.push({type:"appendToolUse",id:e,name:t,args:a})}appendToolResult(e,t,a=!1){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}}),this.calls.push({type:"appendToolResult",toolUseId:e,content:t,...a?{isError:!0}:{}})}}const stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},buildDeps=(e,t,a,o)=>({stream:e,openReasoningStream:o?()=>o:void 0,abortSignal:t,callbackQueue:a,logger:stubLogger(),requestId:"im_req_test",channelId:"ch_test",inboundMessageId:"msg_test",origin:"im"});describe("createImReplySink",()=>{let e,t,a,o;beforeEach(()=>{e=new FakeMessageStream,t=new FakeMessageStream,a=new AbortController,o=new SerializedCallbackQueue}),it("onPartialReply: extracts delta from cumulative snapshot and appendBody's the suffix",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"Hello"}),t.replyOptions.onPartialReply({text:"Hello, world"}),t.replyOptions.onPartialReply({text:"Hello, world!"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"Hello"},{type:"appendBody",chunk:", world"},{type:"appendBody",chunk:"!"}]),expect(e.body).toBe("Hello, world!")}),it("onPartialReply: drops shorter-prefix snapshots (runtime echo of older state)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"abcdef"}),t.replyOptions.onPartialReply({text:"abc"}),t.replyOptions.onPartialReply({text:"abcdefg"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"abcdef"},{type:"appendBody",chunk:"g"}])}),it("onPartialReply: treats non-prefix snapshot as a reset (setBody replace, NOT append)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"alpha"}),t.replyOptions.onPartialReply({text:"beta"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"alpha"},{type:"setBody",text:"beta"}]),expect(e.body).toBe("beta")}),it("sendFinalReply: setBody to the canonical full text",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"partial..."}),t.dispatcher.sendFinalReply({text:"canonical final body"}),await o.waitForIdle(),expect(e.calls.at(-1)).toEqual({type:"setBody",text:"canonical final body"}),expect(e.body).toBe("canonical final body"),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:0,final:1})}),it("sendBlockReply: setBody for finalized block (block_reply semantics)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.dispatcher.sendBlockReply({text:"block contents"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"setBody",text:"block contents"}]),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:1,final:0})}),it("onReasoningStream: appends cumulative-snapshot deltas onto the agent_reasoning row (NOT the reply row)",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onReasoningStream({text:"thinking step 1"}),l.replyOptions.onReasoningStream({text:"thinking step 1 and 2"}),l.replyOptions.onReasoningEnd(),await o.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"thinking step 1"},{type:"appendBody",chunk:" and 2"}]),expect(t.body).toBe("thinking step 1 and 2"),expect(e.calls).toEqual([{type:"appendThinking",text:"thinking step 1 and 2",state:"done"}])}),it("onReasoningStream → onReasoningEnd → onReasoningStream: second burst appends after a newline; two reply thinking parts",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onReasoningStream({text:"first"}),l.replyOptions.onReasoningEnd(),l.replyOptions.onReasoningStream({text:"second"}),l.replyOptions.onReasoningEnd(),await o.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"first"},{type:"appendBody",chunk:"\n"},{type:"appendBody",chunk:"second"}]),expect(t.body).toBe("first\nsecond"),expect(e.calls).toEqual([{type:"appendThinking",text:"first",state:"done"},{type:"appendThinking",text:"second",state:"done"}])}),it("onReasoningStream: non-prefix branch mid-burst replaces the row (committed prefix + new text)",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onReasoningStream({text:"alpha"}),l.replyOptions.onReasoningStream({text:"beta gamma"}),await o.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"alpha"},{type:"setBody",text:"beta gamma"}]),expect(t.body).toBe("beta gamma")}),it("onReasoningStream without an openReasoningStream factory: reasoning chunks are dropped",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onReasoningStream({text:"ignored"}),t.replyOptions.onReasoningEnd(),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("uses the runtime toolCallId and completes tool_use before appending its result",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"calc",toolCallId:"call_calc",arguments:{expression:"6*7"}}),t.dispatcher.sendToolResult({text:"42",toolCallId:"call_calc"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"call_calc",name:"calc",args:{expression:"6*7"}},{type:"replacePart",index:0,part:{type:"tool_use",id:"call_calc",name:"calc",arguments:{expression:"6*7"},state:"done"}},{type:"appendToolResult",toolUseId:"call_calc",content:"42"}]),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:1,block:0,final:0})}),it("resolves tool_call_id from channelData.im before generic channelData",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"nested",channelData:{tool_call_id:"generic_id",im:{tool_call_id:"im_id"}}}),t.dispatcher.sendToolResult({text:"nested result",channelData:{tool_call_id:"generic_id",im:{toolCallId:"im_id"}}}),await o.waitForIdle(),expect(e.calls[0]).toMatchObject({type:"appendToolUse",id:"im_id"}),expect(e.calls.at(-1)).toEqual({type:"appendToolResult",toolUseId:"im_id",content:"nested result"})}),it("drops id-less results when multiple pending tools make correlation ambiguous",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"first"}),t.replyOptions.onToolStart({name:"second"}),t.dispatcher.sendToolResult({text:"first result"}),t.dispatcher.sendToolResult({text:"second result"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([]),t.dispatcher.markComplete("success"),expect(e.calls).toEqual([])}),it("preserves an id-less result when exactly one pending tool makes correlation unambiguous",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"read",args:{path:"/tmp/a"}}),t.dispatcher.sendToolResult({text:"file contents",mediaUrls:["beeos-file://preview"],isError:!0}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool_im_req_test_1",content:{text:"file contents",media_urls:["beeos-file://preview"]},isError:!0}])}),it("does not guess identity when repeated id-less starts are ambiguous",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"exec",args:{command:"long task"}}),t.replyOptions.onToolStart({name:"exec"}),t.replyOptions.onToolStart({name:"exec"}),t.dispatcher.sendToolResult({text:"done",toolName:"exec"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("does not attach a named result to the only pending tool when names disagree",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"read",phase:"start"}),t.dispatcher.sendToolResult({text:"opened",toolName:"browser"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("matches the OpenClaw 2026.5.22 item-start then id-less tool-start order without duplicate cards",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:call1",name:"read",phase:"start"}),t.replyOptions.onToolStart({name:"read",args:{path:"/tmp/a"},phase:"start"}),t.replyOptions.onItemEvent({kind:"command",itemId:"command:call1",name:"cat /tmp/a",phase:"start"}),t.dispatcher.sendToolResult({text:"file contents",toolName:"read"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"tool:call1",name:"read",args:{}}]),expect(e.calls.filter(e=>"replacePart"===e.type)).toContainEqual({type:"replacePart",index:0,part:{type:"tool_use",id:"tool:call1",name:"read",arguments:{path:"/tmp/a"},state:"streaming"}}),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool:call1",content:"file contents"}])}),it("correlates legacy-start then item-start then repeated legacy-start",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"exec",args:{command:"sleep 5"},phase:"start"}),t.replyOptions.onItemEvent({kind:"tool",itemId:"item-exec",name:"exec",phase:"start"}),t.replyOptions.onToolStart({name:"exec",phase:"start"}),t.dispatcher.sendToolResult({text:"done",toolName:"exec"}),t.replyOptions.onItemEvent({kind:"tool",itemId:"item-exec",name:"exec",phase:"end",status:"completed"}),t.dispatcher.sendToolResult({text:"done",toolName:"exec"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"item-exec",name:"exec",args:{command:"sleep 5"}}]),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"item-exec",content:"done"}])}),it("never binds a named legacy start to a differently named stable item",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"item-read",name:"read",phase:"start"}),t.replyOptions.onToolStart({name:"exec",phase:"start"}),t.dispatcher.sendToolResult({text:"exec done",toolName:"exec"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"item-read",name:"read",args:{}},{type:"appendToolUse",id:"tool_im_req_test_1",name:"exec",args:{}}]),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool_im_req_test_1",content:"exec done"}])}),it("ignores update and non-tool item events before creating any tool entity",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:unknown",name:"read",phase:"update"}),t.replyOptions.onItemEvent({kind:"command",itemId:"command:unknown",name:"cat file",phase:"end",summary:"output"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("keeps concurrent stable item starts with the same tool name distinct",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:call1",name:"read",phase:"start"}),t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:call2",name:"read",phase:"start"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"tool:call1",name:"read",args:{}},{type:"appendToolUse",id:"tool:call2",name:"read",args:{}}])}),it("uses stable item events to correlate concurrent tools that finish out of order",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"exec",phase:"start"}),t.replyOptions.onToolStart({name:"process",phase:"start"}),t.replyOptions.onItemEvent({itemId:"item-process",name:"process",phase:"end",status:"completed",summary:"process done"}),t.replyOptions.onItemEvent({itemId:"item-exec",name:"exec",phase:"end",status:"completed",summary:"exec done"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"item-process",content:"process done"},{type:"appendToolResult",toolUseId:"item-exec",content:"exec done"}])}),it("reuses an explicit tool id for multiple result events without duplicating tool_use",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"process",toolCallId:"call_process"}),t.dispatcher.sendToolResult({text:"running",toolCallId:"call_process"}),t.dispatcher.sendToolResult({text:"complete",tool_call_id:"call_process"}),t.dispatcher.sendToolResult({text:"complete",tool_call_id:"call_process"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"replacePart"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"call_process",content:"running"},{type:"appendToolResult",toolUseId:"call_process",content:"complete"}])}),it("creates a matching tool_use when an explicit result arrives before start",async()=>{createImReplySink(buildDeps(e,a.signal,o)).dispatcher.sendToolResult({text:"early result",toolCallId:"call_early",toolName:"early-tool"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"call_early",name:"early-tool",args:{}},{type:"replacePart",index:0,part:{type:"tool_use",id:"call_early",name:"early-tool",arguments:{},state:"done"}},{type:"appendToolResult",toolUseId:"call_early",content:"early result"}])}),it("ignores an orphan legacy summary instead of inventing a generic tool card",async()=>{createImReplySink(buildDeps(e,a.signal,o)).dispatcher.sendToolResult({text:"orphan",isError:!0}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("ignores id-less update callbacks and aliases a later item id to the original card",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"browser",phase:"start",args:{action:"open"}}),t.replyOptions.onToolStart({name:"browser",phase:"update"}),t.replyOptions.onItemEvent({itemId:"item-browser",name:"browser",phase:"end",status:"completed",summary:"opened"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"item-browser",content:"opened"}])}),it("preserves callback order across interleaved partial / reasoning / tool events",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onPartialReply({text:"A"}),l.replyOptions.onReasoningStream({text:"think 1"}),l.replyOptions.onPartialReply({text:"AB"}),l.replyOptions.onToolStart({name:"tool1",phase:"start"}),l.replyOptions.onItemEvent({itemId:"item-tool1",name:"tool1",phase:"end",status:"completed",summary:"result1"}),l.replyOptions.onPartialReply({text:"ABC"}),l.replyOptions.onReasoningEnd(),l.dispatcher.sendFinalReply({text:"ABC final"}),await o.waitForIdle();const n=e.calls.map(e=>e.type);expect(n).toEqual(["appendBody","appendBody","appendToolUse","replacePart","appendToolResult","appendBody","appendThinking","setBody"]),expect(t.calls).toEqual([{type:"appendBody",chunk:"think 1"}])}),it("aborted signal suppresses ALL further dispatcher / replyOptions calls",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"before"}),await o.waitForIdle(),a.abort("test_abort"),t.replyOptions.onPartialReply({text:"before-after"}),t.dispatcher.sendFinalReply({text:"should-not-fire"}),t.replyOptions.onReasoningStream({text:"blocked"}),t.replyOptions.onToolStart({name:"blocked"}),t.dispatcher.sendToolResult({text:"blocked"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"before"}])}),it("markComplete suppresses subsequent callbacks (idempotent terminal)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"live"}),t.dispatcher.markComplete("success"),t.replyOptions.onPartialReply({text:"live-after"}),t.dispatcher.sendFinalReply({text:"after-complete"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"live"}])}),it("does not mark unfinished tools done when the enclosing run fails",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"browser",phase:"start"}),t.dispatcher.markComplete("failure"),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"tool_im_req_test_1",name:"browser",args:{}}]),expect(e.parts).toEqual([{type:"tool_use",id:"tool_im_req_test_1",name:"browser",arguments:{},state:"streaming"}])}),it("getQueuedCounts returns a snapshot copy (mutation does not leak)",()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.dispatcher.getQueuedCounts().tool=999,expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:0,final:0})}),it("when the stream is already terminated, callbacks are silently dropped",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));e.isTerminated=!0,t.replyOptions.onPartialReply({text:"dropped"}),t.dispatcher.sendFinalReply({text:"dropped"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("sink callback throwing inside guarded does not poison the queue (logged as warn)",async()=>{const t=buildDeps(e,a.signal,o),l=createImReplySink(t),n=e.appendBody.bind(e);let s=!0;e.appendBody=e=>{if(s)throw s=!1,new Error("synthetic appendBody failure");return n(e)},l.replyOptions.onPartialReply({text:"trigger"}),l.replyOptions.onPartialReply({text:"trigger ok"}),await o.waitForIdle(),expect(e.body).toBe(" ok"),expect(t.logger.lines.some(e=>e.includes("[im-sink] partial mapping failed"))).toBe(!0)})});
|
|
1
|
+
import{describe,it,expect,beforeEach}from"vitest";import{SerializedCallbackQueue}from"./callback-queue.js";import{createImReplySink}from"./im-reply-sink.js";class FakeMessageStream{calls=[];parts=[];body="";isTerminated=!1;appendBody(e){if(this.isTerminated)throw new Error("terminated");this.body+=e,this.calls.push({type:"appendBody",chunk:e})}setBody(e){if(this.isTerminated)throw new Error("terminated");this.body=e,this.calls.push({type:"setBody",text:e})}addPart(e){if(this.isTerminated)throw new Error("terminated");this.parts.push(e),this.calls.push({type:"addPart",part:e})}replacePart(e,t){if(this.isTerminated)throw new Error("terminated");this.parts[e]=t,this.calls.push({type:"replacePart",index:e,part:t})}appendThinking(e,t="done"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"thinking",text:e,state:t}),this.calls.push({type:"appendThinking",text:e,state:t})}appendToolUse(e,t,a,o="streaming"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:o}),this.calls.push({type:"appendToolUse",id:e,name:t,args:a})}appendToolResult(e,t,a=!1){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}}),this.calls.push({type:"appendToolResult",toolUseId:e,content:t,...a?{isError:!0}:{}})}}const stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},buildDeps=(e,t,a,o)=>({stream:e,openReasoningStream:o?()=>o:void 0,abortSignal:t,callbackQueue:a,logger:stubLogger(),requestId:"im_req_test",channelId:"ch_test",inboundMessageId:"msg_test",origin:"im"});describe("createImReplySink",()=>{let e,t,a,o;beforeEach(()=>{e=new FakeMessageStream,t=new FakeMessageStream,a=new AbortController,o=new SerializedCallbackQueue}),it("onPartialReply: extracts delta from cumulative snapshot and appendBody's the suffix",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"Hello"}),t.replyOptions.onPartialReply({text:"Hello, world"}),t.replyOptions.onPartialReply({text:"Hello, world!"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"Hello"},{type:"appendBody",chunk:", world"},{type:"appendBody",chunk:"!"}]),expect(e.body).toBe("Hello, world!")}),it("onPartialReply: drops shorter-prefix snapshots (runtime echo of older state)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"abcdef"}),t.replyOptions.onPartialReply({text:"abc"}),t.replyOptions.onPartialReply({text:"abcdefg"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"abcdef"},{type:"appendBody",chunk:"g"}])}),it("onPartialReply: treats non-prefix snapshot as a reset (setBody replace, NOT append)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"alpha"}),t.replyOptions.onPartialReply({text:"beta"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"alpha"},{type:"setBody",text:"beta"}]),expect(e.body).toBe("beta")}),it("sendFinalReply: setBody to the canonical full text",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"partial..."}),t.dispatcher.sendFinalReply({text:"canonical final body"}),await o.waitForIdle(),expect(e.calls.at(-1)).toEqual({type:"setBody",text:"canonical final body"}),expect(e.body).toBe("canonical final body"),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:0,final:1})}),it("sendBlockReply: setBody for finalized block (block_reply semantics)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.dispatcher.sendBlockReply({text:"block contents"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"setBody",text:"block contents"}]),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:1,final:0})}),it("onReasoningStream: appends cumulative-snapshot deltas onto the agent_reasoning row (NOT the reply row)",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onReasoningStream({text:"thinking step 1"}),l.replyOptions.onReasoningStream({text:"thinking step 1 and 2"}),l.replyOptions.onReasoningEnd(),await o.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"thinking step 1"},{type:"appendBody",chunk:" and 2"}]),expect(t.body).toBe("thinking step 1 and 2"),expect(e.calls).toEqual([{type:"appendThinking",text:"thinking step 1 and 2",state:"done"}])}),it("onReasoningStream → onReasoningEnd → onReasoningStream: second burst appends after a newline; two reply thinking parts",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onReasoningStream({text:"first"}),l.replyOptions.onReasoningEnd(),l.replyOptions.onReasoningStream({text:"second"}),l.replyOptions.onReasoningEnd(),await o.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"first"},{type:"appendBody",chunk:"\n"},{type:"appendBody",chunk:"second"}]),expect(t.body).toBe("first\nsecond"),expect(e.calls).toEqual([{type:"appendThinking",text:"first",state:"done"},{type:"appendThinking",text:"second",state:"done"}])}),it("onReasoningStream: non-prefix branch mid-burst replaces the row (committed prefix + new text)",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onReasoningStream({text:"alpha"}),l.replyOptions.onReasoningStream({text:"beta gamma"}),await o.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"alpha"},{type:"setBody",text:"beta gamma"}]),expect(t.body).toBe("beta gamma")}),it("onReasoningStream without an openReasoningStream factory: reasoning chunks are dropped",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onReasoningStream({text:"ignored"}),t.replyOptions.onReasoningEnd(),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("uses the runtime toolCallId and completes tool_use before appending its result",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"calc",toolCallId:"call_calc",arguments:{expression:"6*7"}}),t.dispatcher.sendToolResult({text:"42",toolCallId:"call_calc"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"call_calc",name:"calc",args:{expression:"6*7"}},{type:"replacePart",index:0,part:{type:"tool_use",id:"call_calc",name:"calc",arguments:{expression:"6*7"},state:"done"}},{type:"appendToolResult",toolUseId:"call_calc",content:"42"}]),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:1,block:0,final:0})}),it("resolves tool_call_id from channelData.im before generic channelData",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"nested",channelData:{tool_call_id:"generic_id",im:{tool_call_id:"im_id"}}}),t.dispatcher.sendToolResult({text:"nested result",channelData:{tool_call_id:"generic_id",im:{toolCallId:"im_id"}}}),await o.waitForIdle(),expect(e.calls[0]).toMatchObject({type:"appendToolUse",id:"im_id"}),expect(e.calls.at(-1)).toEqual({type:"appendToolResult",toolUseId:"im_id",content:"nested result"})}),it("drops id-less results when multiple pending tools make correlation ambiguous",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"first"}),t.replyOptions.onToolStart({name:"second"}),t.dispatcher.sendToolResult({text:"first result"}),t.dispatcher.sendToolResult({text:"second result"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([]),t.dispatcher.markComplete("success"),expect(e.calls).toEqual([])}),it("preserves an id-less result when exactly one pending tool makes correlation unambiguous",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"read",args:{path:"/tmp/a"}}),t.dispatcher.sendToolResult({text:"file contents",mediaUrls:["beeos-file://preview"],isError:!0}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool_im_req_test_1",content:{text:"file contents",media_urls:["beeos-file://preview"]},isError:!0}])}),it("does not guess identity when repeated id-less starts are ambiguous",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"exec",args:{command:"long task"}}),t.replyOptions.onToolStart({name:"exec"}),t.replyOptions.onToolStart({name:"exec"}),t.dispatcher.sendToolResult({text:"done",toolName:"exec"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("does not attach a named result to the only pending tool when names disagree",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"read",phase:"start"}),t.dispatcher.sendToolResult({text:"opened",toolName:"browser"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("matches the OpenClaw 2026.5.22 item-start then id-less tool-start order without duplicate cards",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:call1",name:"read",phase:"start"}),t.replyOptions.onToolStart({name:"read",args:{path:"/tmp/a"},phase:"start"}),t.replyOptions.onItemEvent({kind:"command",itemId:"command:call1",name:"cat /tmp/a",phase:"start"}),t.dispatcher.sendToolResult({text:"file contents",toolName:"read"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"tool:call1",name:"read",args:{}}]),expect(e.calls.filter(e=>"replacePart"===e.type)).toContainEqual({type:"replacePart",index:0,part:{type:"tool_use",id:"tool:call1",name:"read",arguments:{path:"/tmp/a"},state:"streaming"}}),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool:call1",content:"file contents"}])}),it("correlates legacy-start then item-start then repeated legacy-start",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"exec",args:{command:"sleep 5"},phase:"start"}),t.replyOptions.onItemEvent({kind:"tool",itemId:"item-exec",name:"exec",phase:"start"}),t.replyOptions.onToolStart({name:"exec",phase:"start"}),t.dispatcher.sendToolResult({text:"done",toolName:"exec"}),t.replyOptions.onItemEvent({kind:"tool",itemId:"item-exec",name:"exec",phase:"end",status:"completed"}),t.dispatcher.sendToolResult({text:"done",toolName:"exec"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"item-exec",name:"exec",args:{command:"sleep 5"}}]),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"item-exec",content:"done"}])}),it("never binds a named legacy start to a differently named stable item",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"item-read",name:"read",phase:"start"}),t.replyOptions.onToolStart({name:"exec",phase:"start"}),t.dispatcher.sendToolResult({text:"exec done",toolName:"exec"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"item-read",name:"read",args:{}},{type:"appendToolUse",id:"tool_im_req_test_1",name:"exec",args:{}}]),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool_im_req_test_1",content:"exec done"}])}),it("ignores update and non-tool item events before creating any tool entity",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:unknown",name:"read",phase:"update"}),t.replyOptions.onItemEvent({kind:"command",itemId:"command:unknown",name:"cat file",phase:"end",summary:"output"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("keeps concurrent stable item starts with the same tool name distinct",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:call1",name:"read",phase:"start"}),t.replyOptions.onItemEvent({kind:"tool",itemId:"tool:call2",name:"read",phase:"start"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"tool:call1",name:"read",args:{}},{type:"appendToolUse",id:"tool:call2",name:"read",args:{}}])}),it("uses stable item events to correlate concurrent tools that finish out of order",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"exec",phase:"start"}),t.replyOptions.onToolStart({name:"process",phase:"start"}),t.replyOptions.onItemEvent({itemId:"item-process",name:"process",phase:"end",status:"completed",summary:"process done"}),t.replyOptions.onItemEvent({itemId:"item-exec",name:"exec",phase:"end",status:"completed",summary:"exec done"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"item-process",content:"process done"},{type:"appendToolResult",toolUseId:"item-exec",content:"exec done"}])}),it("keeps an item end without status pending when the enclosing run fails",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onItemEvent({kind:"tool",itemId:"item-exec",name:"exec",phase:"end",summary:"run sleep 90 -> print text"}),await o.waitForIdle(),t.dispatcher.markComplete("failure"),expect(e.calls).toEqual([{type:"appendToolUse",id:"item-exec",name:"exec",args:{}}]),expect(e.parts).toEqual([{type:"tool_use",id:"item-exec",name:"exec",arguments:{},state:"streaming"}])}),it("finalizes an item end with explicit completed status as success",async()=>{createImReplySink(buildDeps(e,a.signal,o)).replyOptions.onItemEvent({kind:"tool",itemId:"item-completed",name:"exec",phase:"end",status:"completed",summary:"done"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"item-completed",name:"exec",args:{}},{type:"replacePart",index:0,part:{type:"tool_use",id:"item-completed",name:"exec",arguments:{},state:"done"}},{type:"appendToolResult",toolUseId:"item-completed",content:"done"}])}),it("finalizes an item end with explicit failed status as an error",async()=>{createImReplySink(buildDeps(e,a.signal,o)).replyOptions.onItemEvent({kind:"tool",itemId:"item-failed",name:"exec",phase:"end",status:"failed",summary:"command failed"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"item-failed",name:"exec",args:{}},{type:"replacePart",index:0,part:{type:"tool_use",id:"item-failed",name:"exec",arguments:{},state:"done"}},{type:"appendToolResult",toolUseId:"item-failed",content:"command failed",isError:!0}])}),it.each(["failed","cancelled"])("appends a generic error result for %s status without summary or meta",async t=>{createImReplySink(buildDeps(e,a.signal,o)).replyOptions.onItemEvent({kind:"tool",itemId:`item-${t}`,name:"exec",phase:"end",status:t}),await o.waitForIdle(),expect(e.calls.at(-1)).toEqual({type:"appendToolResult",toolUseId:`item-${t}`,content:"Tool execution failed.",isError:!0}),expect(e.parts).toContainEqual({type:"tool_result",tool_use_id:`item-${t}`,content:"Tool execution failed.",is_error:!0})}),it("reuses an explicit tool id for multiple result events without duplicating tool_use",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"process",toolCallId:"call_process"}),t.dispatcher.sendToolResult({text:"running",toolCallId:"call_process"}),t.dispatcher.sendToolResult({text:"complete",tool_call_id:"call_process"}),t.dispatcher.sendToolResult({text:"complete",tool_call_id:"call_process"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"replacePart"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"call_process",content:"running"},{type:"appendToolResult",toolUseId:"call_process",content:"complete"}])}),it("creates a matching tool_use when an explicit result arrives before start",async()=>{createImReplySink(buildDeps(e,a.signal,o)).dispatcher.sendToolResult({text:"early result",toolCallId:"call_early",toolName:"early-tool"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"call_early",name:"early-tool",args:{}},{type:"replacePart",index:0,part:{type:"tool_use",id:"call_early",name:"early-tool",arguments:{},state:"done"}},{type:"appendToolResult",toolUseId:"call_early",content:"early result"}])}),it("ignores an orphan legacy summary instead of inventing a generic tool card",async()=>{createImReplySink(buildDeps(e,a.signal,o)).dispatcher.sendToolResult({text:"orphan",isError:!0}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("ignores id-less update callbacks and aliases a later item id to the original card",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"browser",phase:"start",args:{action:"open"}}),t.replyOptions.onToolStart({name:"browser",phase:"update"}),t.replyOptions.onItemEvent({itemId:"item-browser",name:"browser",phase:"end",status:"completed",summary:"opened"}),await o.waitForIdle(),expect(e.calls.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"item-browser",content:"opened"}])}),it("preserves callback order across interleaved partial / reasoning / tool events",async()=>{const l=createImReplySink(buildDeps(e,a.signal,o,t));l.replyOptions.onPartialReply({text:"A"}),l.replyOptions.onReasoningStream({text:"think 1"}),l.replyOptions.onPartialReply({text:"AB"}),l.replyOptions.onToolStart({name:"tool1",phase:"start"}),l.replyOptions.onItemEvent({itemId:"item-tool1",name:"tool1",phase:"end",status:"completed",summary:"result1"}),l.replyOptions.onPartialReply({text:"ABC"}),l.replyOptions.onReasoningEnd(),l.dispatcher.sendFinalReply({text:"ABC final"}),await o.waitForIdle();const n=e.calls.map(e=>e.type);expect(n).toEqual(["appendBody","appendBody","appendToolUse","replacePart","appendToolResult","appendBody","appendThinking","setBody"]),expect(t.calls).toEqual([{type:"appendBody",chunk:"think 1"}])}),it("aborted signal suppresses ALL further dispatcher / replyOptions calls",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"before"}),await o.waitForIdle(),a.abort("test_abort"),t.replyOptions.onPartialReply({text:"before-after"}),t.dispatcher.sendFinalReply({text:"should-not-fire"}),t.replyOptions.onReasoningStream({text:"blocked"}),t.replyOptions.onToolStart({name:"blocked"}),t.dispatcher.sendToolResult({text:"blocked"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"before"}])}),it("markComplete suppresses subsequent callbacks (idempotent terminal)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onPartialReply({text:"live"}),t.dispatcher.markComplete("success"),t.replyOptions.onPartialReply({text:"live-after"}),t.dispatcher.sendFinalReply({text:"after-complete"}),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"live"}])}),it("does not mark unfinished tools done when the enclosing run fails",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.replyOptions.onToolStart({name:"browser",phase:"start"}),t.dispatcher.markComplete("failure"),await o.waitForIdle(),expect(e.calls).toEqual([{type:"appendToolUse",id:"tool_im_req_test_1",name:"browser",args:{}}]),expect(e.parts).toEqual([{type:"tool_use",id:"tool_im_req_test_1",name:"browser",arguments:{},state:"streaming"}])}),it("getQueuedCounts returns a snapshot copy (mutation does not leak)",()=>{const t=createImReplySink(buildDeps(e,a.signal,o));t.dispatcher.getQueuedCounts().tool=999,expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:0,final:0})}),it("when the stream is already terminated, callbacks are silently dropped",async()=>{const t=createImReplySink(buildDeps(e,a.signal,o));e.isTerminated=!0,t.replyOptions.onPartialReply({text:"dropped"}),t.dispatcher.sendFinalReply({text:"dropped"}),await o.waitForIdle(),expect(e.calls).toEqual([])}),it("sink callback throwing inside guarded does not poison the queue (logged as warn)",async()=>{const t=buildDeps(e,a.signal,o),l=createImReplySink(t),n=e.appendBody.bind(e);let s=!0;e.appendBody=e=>{if(s)throw s=!1,new Error("synthetic appendBody failure");return n(e)},l.replyOptions.onPartialReply({text:"trigger"}),l.replyOptions.onPartialReply({text:"trigger ok"}),await o.waitForIdle(),expect(e.body).toBe(" ok"),expect(t.logger.lines.some(e=>e.includes("[im-sink] partial mapping failed"))).toBe(!0)})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const observeLifecyclePromise=(e,o,s)=>{e.catch(()=>{o.error?.(`[session-lifecycle] async_rejection component=${s.component} action=${s.action} session_id=${s.sessionId??""} gateway_session_key=${s.gatewaySessionKey??""} error_code=lifecycle_promise_rejected`)})};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{observeLifecyclePromise}from"./lifecycle-promise-observer.js";describe("observeLifecyclePromise",()=>{it("catches lifecycle rejection and emits a structured redacted log",async()=>{const e={error:vi.fn()};observeLifecyclePromise(Promise.reject(new Error("secret runtime detail")),e,{component:"gateway-events",action:"chat_final",sessionId:"session-1",gatewaySessionKey:"agent:main:one"}),await Promise.resolve(),await Promise.resolve(),expect(e.error).toHaveBeenCalledWith(expect.stringContaining("component=gateway-events action=chat_final session_id=session-1 gateway_session_key=agent:main:one error_code=lifecycle_promise_rejected")),expect(e.error.mock.calls[0]?.[0]).not.toContain("secret runtime detail")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const RUN_KEY_SEPARATOR="\0";export const promptRunKey=(e,n)=>`${e}\0${n}`;export class PromptRunStore{byRunKey=new Map;size(){return this.byRunKey.size}register(e,n){
|
|
1
|
+
const RUN_KEY_SEPARATOR="\0";export const promptRunKey=(e,n)=>`${e}\0${n}`;export class PromptRunStore{byRunKey=new Map;size(){return this.byRunKey.size}register(e,n){return!this.byRunKey.has(e)&&(n.phase="running",this.byRunKey.set(e,n),!0)}claimTerminal(e,n){const s=this.byRunKey.get(e);return!(!s||"terminal_committing"===s.phase)&&(("cancelling"!==s.phase||"completion"!==n)&&(s.phase="terminal_committing",!0))}phase(e){return this.byRunKey.get(e)?.phase}async abort(e,n){const s=this.byRunKey.get(e);if(!s)return{ok:!1,reason:"not_found"};if("terminal_committing"===s.phase)return{ok:!1,reason:"terminal_committing",requestId:s.requestId,channelId:s.channelId,...s.sessionKey?{sessionKey:s.sessionKey}:{}};if("cancelling"===s.phase)return{ok:!1,reason:"already_aborted",requestId:s.requestId,channelId:s.channelId,...s.sessionKey?{sessionKey:s.sessionKey}:{}};s.phase="cancelling";return(await s.requestCancel(n)).ok?{ok:!0,requestId:s.requestId,channelId:s.channelId,...s.sessionKey?{sessionKey:s.sessionKey}:{}}:{ok:!1,reason:"recovery_required",requestId:s.requestId,channelId:s.channelId,...s.sessionKey?{sessionKey:s.sessionKey}:{}}}unregister(e){this.byRunKey.delete(e)}snapshot(){return Array.from(this.byRunKey.entries(),([e,n])=>[e,{...n}])}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,
|
|
1
|
+
import{beforeEach,describe,expect,it,vi}from"vitest";import{PromptRunStore}from"./prompt-run-store.js";const makeEntry=(e={})=>({requestId:"im_req_test_001",channelId:"ch_test_001",sessionKey:"agent:main:test",requestCancel:vi.fn(async()=>({ok:!0})),startedAt:Date.now(),...e});describe("PromptRunStore",()=>{let e;beforeEach(()=>{e=new PromptRunStore}),it("starts empty and registers one run",()=>{const t=makeEntry();expect(e.register("msg_001",t)).toBe(!0),expect(e.size()).toBe(1),expect(e.snapshot()).toEqual([["msg_001",t]])}),it("rejects a duplicate without cancelling the first run",()=>{const t=makeEntry({requestId:"req_1"}),a=makeEntry({requestId:"req_2"});expect(e.register("msg_dup",t)).toBe(!0),expect(e.register("msg_dup",a)).toBe(!1),expect(t.requestCancel).not.toHaveBeenCalled(),expect(a.requestCancel).not.toHaveBeenCalled()}),it("awaits the cancellation operation before acknowledging cancel",async()=>{let t;const a=vi.fn(()=>new Promise(e=>{t=()=>e({ok:!0})}));e.register("msg_cancel",makeEntry({requestCancel:a}));const r=e.abort("msg_cancel","chat_cancel:user_request");await Promise.resolve();let n=!1;r.then(()=>{n=!0}),await Promise.resolve(),expect(n).toBe(!1),t(),await expect(r).resolves.toMatchObject({ok:!0,requestId:"im_req_test_001",sessionKey:"agent:main:test"}),expect(a).toHaveBeenCalledWith("chat_cancel:user_request")}),it("rejects cancellation after the terminal writer wins the CAS",async()=>{const t=makeEntry({requestId:"req_terminal"});e.register("msg_terminal",t),expect(e.claimTerminal("msg_terminal","completion")).toBe(!0),await expect(e.abort("msg_terminal","chat_cancel:too_late")).resolves.toMatchObject({ok:!1,reason:"terminal_committing"}),expect(t.requestCancel).not.toHaveBeenCalled()}),it("lets cancellation, but not normal completion, claim a cancelling run",async()=>{e.register("msg_cancel_race",makeEntry()),await expect(e.abort("msg_cancel_race","chat_cancel:user_request")).resolves.toMatchObject({ok:!0}),expect(e.claimTerminal("msg_cancel_race","completion")).toBe(!1),expect(e.claimTerminal("msg_cancel_race","cancellation")).toBe(!0)}),it("reports structured recovery when cancellation cannot be acknowledged",async()=>{e.register("msg_recovery",makeEntry({requestCancel:async()=>({ok:!1})})),await expect(e.abort("msg_recovery","chat_cancel:user_request")).resolves.toMatchObject({ok:!1,reason:"recovery_required",sessionKey:"agent:main:test"})}),it("unregisters idempotently and snapshots defensively",()=>{e.register("msg_snap",makeEntry());e.snapshot().length=0,expect(e.size()).toBe(1),e.unregister("msg_snap"),e.unregister("msg_snap"),expect(e.size()).toBe(0)})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SESSION_RECOVERY_REQUIRED_CODE="session_recovery_required";export const SESSION_RECOVERY_REQUIRED_MESSAGE="The session could not be proven safe for another write. Recovery is required.";const DEFAULT_OBSERVATION_TIMEOUT_MS=13e4,DEFAULT_CANCELLATION_TERMINAL_OBSERVATION_TIMEOUT_MS=3e4;export class SessionMutationCoordinator{gate;quiescence;logger;states=new Map;observationTimeoutMs;cancellationTerminalObservationTimeoutMs;setTimer;clearTimer;constructor(e,r,t,o={}){this.gate=e,this.quiescence=r,this.logger=t,this.observationTimeoutMs=o.observationTimeoutMs??13e4,this.cancellationTerminalObservationTimeoutMs=o.cancellationTerminalObservationTimeoutMs??3e4,this.setTimer=o.setTimer??((e,r)=>{const t=setTimeout(e,r);return t.unref?.(),t}),this.clearTimer=o.clearTimer??(e=>clearTimeout(e))}tryStartInteractive(e,r){const t=this.gate.tryStartInteractive(e);return t.ok&&this.register(t.token,r,"attachable"),t}tryStartExclusive(e,r){const t=this.gate.tryStartInteractive(e);return t.ok&&this.register(t.token,r,"exclusive"),t}async acquireInternal(e){const r=await this.gate.acquireInternal(e);return this.register(r,"internal","attachable"),r}bindRun(e,r){if(!e||!r)return;const t=this.states.get(e.operationId);t&&"recovery_required"!==t.phase&&(t.runId=r)}attachOrStartExternal(e,r,t){const o=this.stateForSession(e.sessionKey);if(o)return o.recovery?{ok:!1,reason:"session_busy",recovery:o.recovery}:"exclusive"===o.attachmentPolicy?{ok:!1,reason:"session_busy"}:o.runId&&t&&o.runId!==t?{ok:!1,reason:"run_conflict",recovery:this.fence(o,"run_conflict")}:(!o.runId&&t&&(o.runId=t),{ok:!0,ownership:"observer",ownerOperationId:o.token.operationId,attached:!0});const s=this.tryStartInteractive(e,r);return s.ok?(t&&this.bindRun(s.token,t),{ok:!0,ownership:"owner",token:s.token,attached:!1}):{ok:!1,reason:s.reason}}requestCancel(e,r,t){const o=this.requireState(e);if(o.recovery)return Promise.resolve({ok:!1,recovery:o.recovery});if(o.cancelPromise)return o.cancelPromise;if(o.terminalKind||o.convergencePromise)return Promise.resolve({ok:!1,reason:"terminal_already_observed"});o.phase="cancelling",o.cancelObservationPromise=new Promise(e=>{o.resolveCancelObservation=e});const s=r.chatAbort(e.sessionKey,o.runId).then(()=>(o.cancelAcknowledged=!0,t?.(),o.terminalKind?this.resolveTerminalObserved(o):o.recovery||(o.cancelObservationTimer=this.setTimer(()=>{const e=this.fence(o,"terminal_not_observed");o.resolveCancelObservation?.({ok:!1,recovery:e}),o.resolveCancelObservation=void 0},this.cancellationTerminalObservationTimeoutMs)),this.logger.debug?.(`[session-mutation] cancel acknowledged sessionKey=${e.sessionKey} operationId=${e.operationId} runId=${o.runId??""}`),{ok:!0,acknowledged:!0,terminalObservation:o.cancelObservationPromise})).catch(()=>({ok:!1,recovery:this.fence(o,"abort_failed")}));return o.cancelPromise=s,s}requestCancelForSession(e,r){const t=this.stateForSession(e);return t?"exclusive"===t.attachmentPolicy?Promise.resolve({ok:!1,reason:"not_cancellable"}):this.requestCancel(t.token,r):Promise.resolve({ok:!1,reason:"no_active_mutation"})}converge(e,r){const t=this.requireState(e);return t.convergencePromise||(t.terminalKind=r,this.resolveTerminalObserved(t),t.convergencePromise=this.proveConvergence(t)),t.convergencePromise}release(e){const r=this.states.get(e.operationId);r&&r.token.sessionKey===e.sessionKey&&"ready_to_release"===r.phase&&(this.clearCancellationTimer(r),this.gate.release(r.token),this.states.delete(e.operationId))}releaseBeforeRuntime(e){if(!e)return;const r=this.states.get(e.operationId);r?.recovery||(r&&this.clearCancellationTimer(r),this.gate.release(e),this.states.delete(e.operationId))}requireRecovery(e,r,t){const o=this.requireState(e);return t&&(o.terminalKind=t),this.fence(o,r)}snapshot(e){for(const r of this.states.values())if(r.token.sessionKey===e)return{sessionKey:e,operationId:r.token.operationId,surface:r.surface,attachmentPolicy:r.attachmentPolicy,phase:r.phase,...r.runId?{runId:r.runId}:{},...r.terminalKind?{terminalKind:r.terminalKind}:{},cancelAcknowledged:r.cancelAcknowledged,...r.recovery?{recovery:r.recovery}:{}}}gateSnapshot(e){return this.gate.snapshot(e)}fenceActive(e){for(const r of this.states.values())r.recovery||this.fence(r,e)}register(e,r,t){this.states.set(e.operationId,{token:e,surface:r,attachmentPolicy:t,phase:"active",cancelAcknowledged:!1})}requireState(e){const r=this.states.get(e.operationId);if(!r||r.token.sessionKey!==e.sessionKey)throw new Error("session_mutation_not_registered");return r}stateForSession(e){for(const r of this.states.values())if(r.token.sessionKey===e)return r}async proveConvergence(e){if(e.recovery)return{ok:!1,recovery:e.recovery};if(!e.terminalKind)return{ok:!1,recovery:this.fence(e,"terminal_not_observed")};if(e.cancelPromise){const r=await e.cancelPromise;if(!r.ok)return"recovery"in r?r:{ok:!1,recovery:this.fence(e,"terminal_not_observed")}}if(e.recovery)return{ok:!1,recovery:e.recovery};let r;e.phase="awaiting_quiescence";try{r=await this.quiescence.waitForQuiescence(e.token.sessionKey,this.observationTimeoutMs)}catch{return e.recovery?{ok:!1,recovery:e.recovery}:{ok:!1,recovery:this.fence(e,"quiescence_failed")}}if(e.recovery)return{ok:!1,recovery:e.recovery};if(!r.quiescent){const t="session_unresolved"===r.reason?"session_unresolved":"quiescence_timeout";return{ok:!1,recovery:this.fence(e,t,r)}}return e.phase="ready_to_release",this.logger.debug?.(`[session-mutation] convergence proven sessionKey=${e.token.sessionKey} operationId=${e.token.operationId} terminal=${e.terminalKind} elapsed_ms=${r.elapsedMs}`),{ok:!0,permit:{sessionKey:e.token.sessionKey,operationId:e.token.operationId}}}fence(e,r,t){if(e.recovery)return e.recovery;const o={code:"session_recovery_required",sessionKey:e.token.sessionKey,operationId:e.token.operationId,reason:r,surface:e.surface,...e.terminalKind?{terminalKind:e.terminalKind}:{},...t?{quiescence:t}:{}};return e.recovery=o,e.phase="recovery_required",this.clearCancellationTimer(e),e.resolveCancelObservation?.({ok:!1,recovery:o}),e.resolveCancelObservation=void 0,this.logger.error?.(`[session-mutation] recovery required sessionKey=${o.sessionKey} operationId=${o.operationId} reason=${o.reason} surface=${o.surface}`),o}resolveTerminalObserved(e){e.recovery||(this.clearCancellationTimer(e),e.resolveCancelObservation?.({ok:!0,terminalObserved:!0}),e.resolveCancelObservation=void 0)}clearCancellationTimer(e){e.cancelObservationTimer&&(this.clearTimer(e.cancelObservationTimer),e.cancelObservationTimer=void 0)}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"./session-mutation-coordinator.js";const deferred=()=>{let e;return{promise:new Promise(t=>{e=t}),resolve:e}};describe("SessionMutationCoordinator",()=>{it("requires both awaited cancel acknowledgement and terminal quiescence",async()=>{const e=new SessionWriteGate,t=deferred(),n=deferred(),a=new SessionMutationCoordinator(e,{waitForQuiescence:vi.fn(async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:5}))},{}),o=a.tryStartInteractive({sessionKey:"agent:main:one"},"acp");if(!o.ok)throw new Error("expected lease");a.bindRun(o.token,"run-1");const s=a.requestCancel(o.token,{chatAbort:vi.fn(async()=>{await n.promise})}),r=a.converge(o.token,"cancelled");await Promise.resolve(),expect(a.snapshot("agent:main:one")?.cancelAcknowledged).toBe(!1),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),n.resolve(),await expect(s).resolves.toMatchObject({ok:!0,acknowledged:!0}),expect(a.snapshot("agent:main:one")?.phase).toBe("awaiting_quiescence"),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),t.resolve();const i=await r;expect(i.ok).toBe(!0),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),i.ok&&a.release(i.permit),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!0)}),it("fails closed and exposes recovery-required when quiescence times out",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!1,reason:"timeout",elapsedMs:20})},{},{observationTimeoutMs:20}),n=t.tryStartInteractive({sessionKey:"agent:main:fenced"},"message_service");if(!n.ok)throw new Error("expected lease");await expect(t.converge(n.token,"timeout")).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"quiescence_timeout",terminalKind:"timeout"}}),expect(t.snapshot("agent:main:fenced")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:fenced"}).ok).toBe(!1)}),it("fences as quiescence_failed when the observer rejects",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>{throw new Error("observer failed")}},{}),n=t.tryStartInteractive({sessionKey:"agent:main:observer-failed"},"acp");if(!n.ok)throw new Error("expected lease");await expect(t.converge(n.token,"error")).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"quiescence_failed"}}),expect(t.snapshot("agent:main:observer-failed")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:observer-failed"}).ok).toBe(!1)}),it("blocks the same gateway session while allowing different sessions concurrently",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});expect(t.tryStartInteractive({sessionKey:"agent:main:a"},"acp").ok).toBe(!0),expect(t.tryStartInteractive({sessionKey:"agent:main:a"},"message_service").ok).toBe(!1),expect(t.tryStartInteractive({sessionKey:"agent:main:b"},"message_service").ok).toBe(!0)}),it("releases a normally completed mutation only after the caller commits its permit",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartInteractive({sessionKey:"agent:main:normal"},"acp");if(!n.ok)throw new Error("expected lease");const a=await t.converge(n.token,"completed");expect(a.ok).toBe(!0),expect(e.snapshot("agent:main:normal").active).toBeDefined(),a.ok&&t.release(a.permit),expect(e.snapshot("agent:main:normal").active).toBeUndefined()}),it.each(["acp","message_service"])("applies identical cancel convergence for the %s surface",async e=>{const t=new SessionWriteGate,n=vi.fn().mockResolvedValue(void 0),a=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),o=a.tryStartInteractive({sessionKey:`agent:main:${e}`},e);if(!o.ok)throw new Error("expected lease");a.bindRun(o.token,"run-shared-contract"),await expect(a.requestCancel(o.token,{chatAbort:n})).resolves.toMatchObject({ok:!0,acknowledged:!0});const s=await a.converge(o.token,"cancelled");expect(s.ok).toBe(!0),expect(n).toHaveBeenCalledWith(`agent:main:${e}`,"run-shared-contract"),expect(a.snapshot(`agent:main:${e}`)).toMatchObject({surface:e,phase:"ready_to_release",terminalKind:"cancelled",cancelAcknowledged:!0})}),it("requests cancellation from the current owner by session key",async()=>{const e=new SessionWriteGate,t=vi.fn().mockResolvedValue(void 0),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),a=n.tryStartInteractive({sessionKey:"agent:main:cancel-by-session"},"message_service");if(!a.ok)throw new Error("expected owner");n.bindRun(a.token,"run-owner"),await expect(n.requestCancelForSession("agent:main:cancel-by-session",{chatAbort:t})).resolves.toMatchObject({ok:!0,acknowledged:!0}),expect(t).toHaveBeenCalledWith("agent:main:cancel-by-session","run-owner"),expect(n.snapshot("agent:main:cancel-by-session")).toMatchObject({operationId:a.token.operationId,phase:"cancelling",cancelAcknowledged:!0});const o=await n.converge(a.token,"cancelled");o.ok&&n.release(o.permit)}),it("reports no active mutation when cancellation has no owner",async()=>{const e=new SessionMutationCoordinator(new SessionWriteGate,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});await expect(e.requestCancelForSession("agent:main:no-owner",{chatAbort:vi.fn()})).resolves.toEqual({ok:!1,reason:"no_active_mutation"})}),it("fences and resolves recovery when cancel ACK is not followed by a terminal",async()=>{const e=new SessionWriteGate;let t;const n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:25,setTimer:e=>(t=e,{unref:()=>{}}),clearTimer:()=>{}}),a=n.tryStartInteractive({sessionKey:"agent:main:no-terminal"},"acp");if(!a.ok)throw new Error("expected lease");const o=await n.requestCancel(a.token,{chatAbort:async()=>{}});if(!o.ok)throw new Error("expected cancel ACK");t(),await expect(o.terminalObservation).resolves.toMatchObject({ok:!1,recovery:{code:"session_recovery_required",reason:"terminal_not_observed"}}),expect(n.snapshot("agent:main:no-terminal")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:no-terminal"}).ok).toBe(!1)}),it("clears the cancel observation timer when terminal arrives",async()=>{const e=new SessionWriteGate,t={unref:()=>{}},n=vi.fn(),a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{},{cancellationTerminalObservationTimeoutMs:25,setTimer:()=>t,clearTimer:n}),o=a.tryStartInteractive({sessionKey:"agent:main:terminal"},"message_service");if(!o.ok)throw new Error("expected lease");const s=await a.requestCancel(o.token,{chatAbort:async()=>{}});if(!s.ok)throw new Error("expected cancel ACK");const r=a.converge(o.token,"cancelled");await expect(s.terminalObservation).resolves.toEqual({ok:!0,terminalObserved:!0}),expect(n).toHaveBeenCalledWith(t),await expect(r).resolves.toMatchObject({ok:!0})}),it("rejects late cancel after terminal convergence has started",async()=>{const e=new SessionWriteGate,t=deferred(),n=vi.fn().mockResolvedValue(void 0),a=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),o=a.tryStartInteractive({sessionKey:"agent:main:late-cancel"},"acp");if(!o.ok)throw new Error("expected lease");const s=a.converge(o.token,"completed");await expect(a.requestCancel(o.token,{chatAbort:n})).resolves.toEqual({ok:!1,reason:"terminal_already_observed"}),expect(n).not.toHaveBeenCalled(),t.resolve(),await expect(s).resolves.toMatchObject({ok:!0})}),it("keeps disconnect recovery fenced when quiescence resolves concurrently",async()=>{const e=new SessionWriteGate,t=deferred(),n=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>(await t.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})},{}),a=n.tryStartInteractive({sessionKey:"agent:main:disconnect-race"},"acp");if(!a.ok)throw new Error("expected lease");const o=n.converge(a.token,"completed");await Promise.resolve(),n.fenceActive("gateway_disconnected"),t.resolve(),await expect(o).resolves.toMatchObject({ok:!1,recovery:{reason:"gateway_disconnected"}}),expect(n.snapshot("agent:main:disconnect-race")?.phase).toBe("recovery_required"),expect(e.tryStartInteractive({sessionKey:"agent:main:disconnect-race"}).ok).toBe(!1)}),it.each([void 0,"run-shared"])("attaches an external run to an existing Message Service owner (owner run=%s)",e=>{const t=new SessionWriteGate,n=new SessionMutationCoordinator(t,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),a=n.tryStartInteractive({sessionKey:"agent:main:shared-owner"},"message_service");if(!a.ok)throw new Error("expected owner");e&&n.bindRun(a.token,e);const o=n.attachOrStartExternal({sessionKey:"agent:main:shared-owner"},"acp","run-shared");expect(o).toEqual({ok:!0,ownership:"observer",ownerOperationId:a.token.operationId,attached:!0}),expect(n.snapshot("agent:main:shared-owner")?.runId).toBe("run-shared")}),it("keeps the owner lease active when an attached observer reaches terminal first",async()=>{const e=new SessionWriteGate,t=vi.fn(async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})),n=new SessionMutationCoordinator(e,{waitForQuiescence:t},{}),a=n.attachOrStartExternal({sessionKey:"agent:main:observer-terminal"},"message_service","run-shared");if(!a.ok||"owner"!==a.ownership)throw new Error("expected owner");const o=n.attachOrStartExternal({sessionKey:"agent:main:observer-terminal"},"acp","run-shared");expect(o).toMatchObject({ok:!0,ownership:"observer",attached:!0}),expect(n.snapshot("agent:main:observer-terminal")?.phase).toBe("active"),expect(e.snapshot("agent:main:observer-terminal").active?.operationId).toBe(a.token.operationId),expect(t).not.toHaveBeenCalled();const s=await n.converge(a.token,"completed");expect(s.ok).toBe(!0),s.ok&&n.release(s.permit),expect(e.snapshot("agent:main:observer-terminal").active).toBeUndefined()}),it("returns an owner token when no mutation exists",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}).attachOrStartExternal({sessionKey:"agent:main:new-external"},"acp","run-new");expect(t).toMatchObject({ok:!0,ownership:"owner",attached:!1,token:{sessionKey:"agent:main:new-external"}})}),it("rejects external attachment and cancellation without mutating an exclusive owner",async()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartExclusive({sessionKey:"agent:main:exclusive"},"acp");if(!n.ok)throw new Error("expected exclusive owner");expect(t.attachOrStartExternal({sessionKey:"agent:main:exclusive"},"message_service","run-external")).toEqual({ok:!1,reason:"session_busy"}),expect(t.snapshot("agent:main:exclusive")).toMatchObject({operationId:n.token.operationId,attachmentPolicy:"exclusive",phase:"active"}),expect(t.snapshot("agent:main:exclusive")?.runId).toBeUndefined(),expect(e.snapshot("agent:main:exclusive").active?.operationId).toBe(n.token.operationId);const a=vi.fn();await expect(t.requestCancelForSession("agent:main:exclusive",{chatAbort:a})).resolves.toEqual({ok:!1,reason:"not_cancellable"}),expect(a).not.toHaveBeenCalled(),expect(t.snapshot("agent:main:exclusive")).toMatchObject({operationId:n.token.operationId,attachmentPolicy:"exclusive",phase:"active"})}),it("fences an existing owner when an external run id conflicts",()=>{const e=new SessionWriteGate,t=new SessionMutationCoordinator(e,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{}),n=t.tryStartInteractive({sessionKey:"agent:main:conflict"},"message_service");if(!n.ok)throw new Error("expected owner");t.bindRun(n.token,"run-owner"),expect(t.attachOrStartExternal({sessionKey:"agent:main:conflict"},"acp","run-external")).toMatchObject({ok:!1,reason:"run_conflict",recovery:{reason:"run_conflict"}}),expect(t.snapshot("agent:main:conflict")?.phase).toBe("recovery_required")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{PromptRunStore}from"../runtime/prompt-run-store.js";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{
|
|
1
|
+
import{PromptRunStore}from"../runtime/prompt-run-store.js";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"../runtime/session-mutation-coordinator.js";export const deferred=()=>{let e,t;return{promise:new Promise((s,n)=>{e=s,t=n}),resolve:e,reject:t}};export class TestMessageStream{body="";isTerminated=!1;terminal;async opened(){return{id:"test-envelope",state:"streaming"}}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(){}replacePart(){}appendThinking(){}appendToolUse(){}appendToolResult(){}async finalize(e){return this.terminal={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"completed"}}async fail(e){return this.terminal={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"test-envelope",state:"failed"}}async cancel(e){return this.terminal={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"test-envelope",state:"cancelled"}}}export class TestMessageClient{streams=[];startStreamCalls=[];messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,replyTo:e.replyTo,type:e.type});const t=new TestMessageStream;return this.streams.push(t),t}};asMessageClient(){return this}}export const createRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const s=t.replyOptions;if(!s?.abortSignal)throw new Error("test runtime expected an abort signal");return e({invocation:t,dispatcher:t.dispatcher,replyOptions:s,abortSignal:s.abortSignal})}}}});const createLogger=()=>({debug:()=>{},info:()=>{},warn:()=>{},error:()=>{}});export const createImDispatchFixture=e=>{const t=new TestMessageClient,s=[],n=new PromptRunStore,o=e?.sessionWriteGate??new SessionWriteGate,r=e?.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{client:t,events:s,promptRunStore:n,args:(e,o={})=>({runtime:e,client:t.asMessageClient(),logger:createLogger(),promptRunStore:n,sessionMutationCoordinator:r,abortTransport:{chatAbort:async()=>{}},origin:"im",onLifecycleEvent:e=>s.push(e),prompt:{channelId:"channel-test",inboundMessageId:"message-test",selfPrincipalId:"agent-test",sessionKey:"agent:main:im:channel-test",dispatchInput:{message:"test prompt"}},...o})}};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"../runtime/session-mutation-coordinator.js";export const createTestSessionMutationCoordinator=(o={},e={waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})})=>{const t=new SessionWriteGate;return{gate:t,coordinator:new SessionMutationCoordinator(t,e,{},o)}};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beeos-claw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.57",
|
|
4
4
|
"description": "OpenClaw plugin that bridges the BeeOS platform with the local OpenClaw Gateway, providing file transfer, ACP bridge, and observability.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BeeOS <dev@beeos.ai>",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export class SessionWriteGateReleaseCoordinator{gate;quiescence;logger;inFlight=new Map;retainedTokens=new Map;stopped=!1;observationTimeoutMs;retryDelayMs;sleep;constructor(e,s,t,i={}){this.gate=e,this.quiescence=s,this.logger=t,this.observationTimeoutMs=i.observationTimeoutMs??13e4,this.retryDelayMs=i.retryDelayMs??1e3,this.sleep=i.sleep??(e=>new Promise(s=>{const t=setTimeout(s,e);t.unref?.()}))}start(){this.stopped=!1}stop(){this.stopped=!0;for(const e of this.retainedTokens.values())this.gate.release(e);this.retainedTokens.clear()}releaseImmediately(e){this.gate.release(e)}releaseAfterQuiescence(e){const s=this.inFlight.get(e.operationId);if(s)return s;this.retainedTokens.set(e.operationId,e);const t=this.reconcile(e).finally(()=>{this.inFlight.delete(e.operationId),this.retainedTokens.delete(e.operationId)});return this.inFlight.set(e.operationId,t),t}async reconcile(e){for(;;){if(this.stopped)return void this.gate.release(e);const s=await this.quiescence.waitForQuiescence(e.sessionKey,this.observationTimeoutMs);if(s.quiescent)return this.gate.release(e),void this.logger.debug?.(`[session-write-gate] released sessionKey=${e.sessionKey} reason=${s.reason} elapsed_ms=${s.elapsedMs}`);if(this.stopped)return void this.gate.release(e);this.logger.error?.(`[session-write-gate] release deferred sessionKey=${e.sessionKey} reason=${s.reason} elapsed_ms=${s.elapsedMs}`),await this.sleep(this.retryDelayMs)}}}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{describe,expect,it}from"vitest";import{SessionWriteGate}from"../acp-gateway/session-write-gate.js";import{SessionWriteGateReleaseCoordinator}from"./session-write-gate-release-coordinator.js";describe("SessionWriteGateReleaseCoordinator",()=>{it("keeps the gate closed after a failed proof and retries until quiescent",async()=>{const e=new SessionWriteGate,t=e.tryStartInteractive({sessionKey:"agent:main:one"});if(!t.ok)throw new Error("expected lease");let s=0;const o=new SessionWriteGateReleaseCoordinator(e,{waitForQuiescence:async()=>(s+=1,1===s?{quiescent:!1,reason:"timeout",elapsedMs:10}:{quiescent:!0,reason:"unlocked",elapsedMs:1})},{},{retryDelayMs:0,sleep:async()=>{}}).releaseAfterQuiescence(t.token);await Promise.resolve(),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!1),await o,expect(s).toBe(2),expect(e.tryStartInteractive({sessionKey:"agent:main:one"}).ok).toBe(!0)})});
|