beeos-claw 0.5.49 → 0.5.51

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.
@@ -1 +1 @@
1
- import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,t,a)=>`${e}_${createHash("sha256").update(`${n}:${t}:${a}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:t,client:a,logger:r,promptRunStore:i}=e;if(!hasRuntimeReplyApi(n))return r.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${t.channelId} in_reply_to=${t.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:t.channelId,inboundMessageId:t.inboundMessageId}),!1;const s=`im_req_${randomUUID().replace(/-/g,"")}`,o=Date.now(),d=e.origin??"im",l={protocol:"im",requestId:s,sessionId:t.sessionKey??"agent:main",sourceMessageId:t.inboundMessageId,chatId:t.channelId,origin:d},c=normalizePromptTimeoutMs(e.promptTimeoutMs),u=promptRunKey(t.channelId,t.inboundMessageId),m=new AbortController;if(!i.register(u,{requestId:s,channelId:t.channelId,abortController:m,startedAt:o}))return r.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${t.inboundMessageId} requestId=${s} channel=${t.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const p=a.messages.startStream({conversationId:t.channelId,id:deterministicMessageId("asst",t.channelId,t.inboundMessageId,t.selfPrincipalId),replyTo:e.inReplyTo??t.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const a="terminal"===n?r.warn:r.debug;a?.(`[im] runtime stream ${n} PATCH error requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${e.message}`)}},{sender:t.selfPrincipalId});let I,g=!1;try{g=!0===(await p.opened()).idempotent}catch(e){r.debug?.(`[im] runtime reply stream open probe failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(g)return r.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:t.channelId,inboundMessageId:t.inboundMessageId}),i.unregister(u),!1;const h=new SerializedCallbackQueue,y=createImReplySink({stream:p,openReasoningStream:()=>(I=a.messages.startStream({conversationId:t.channelId,id:deterministicMessageId("rsn",t.channelId,t.inboundMessageId,t.selfPrincipalId),replyTo:e.inReplyTo??t.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const a="terminal"===n?r.warn:r.debug;a?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${e.message}`)}},{sender:t.selfPrincipalId}),I),abortSignal:m.signal,callbackQueue:h,logger:r,requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,origin:d}),f=async(e,n)=>{if(!I)return!1;try{return"cancel"===e?await I.cancel({stopReason:"user_stop"}):"fail"===e?await I.fail(n?{stopReason:n}:void 0):await I.finalize({stopReason:"end_turn"}),!1}catch(e){return r.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${s} channel=${t.channelId}: ${formatErrMessage(e)}`),!0}};r.info?.(`[im] runtime dispatching requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} origin=${d}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId});let b,$,M=!1;const _=async()=>{y.dispatcher.markComplete();try{await y.dispatcher.waitForIdle()}catch{}},w=()=>{if(!m.signal.aborted)return;const e=m.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))};try{const a=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:t.dispatchInput,replyRoute:l,replySinks:{im:y},onDispatchInvoked:()=>{M=!0}}),i=void 0!==c?Promise.race([a,new Promise((e,n)=>{b=setTimeout(()=>{$=new Error(`IM runtime dispatch timed out after ${c}ms`),r.warn?.(`[im] runtime dispatch timeout requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} timeout_ms=${c}`),m.abort($),n($)},c)})]):a,d=await i.finally(()=>{b&&(clearTimeout(b),b=void 0)});w(),await _(),w();const u=await f("complete");let I=!1;try{await p.finalize({stopReason:"end_turn"})}catch(e){I=!0,r.warn?.(`[im] runtime reply stream finalize failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${formatErrMessage(e)}`)}return r.info?.(`[im] runtime dispatch completed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} counts=${JSON.stringify(d.counts)} elapsed_ms=${Date.now()-o}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,counts:d.counts,...I?{terminalWriteFailed:!0}:{},...u?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await _();const a=m.signal.aborted,i=a?m.signal.reason:void 0,o=a&&"string"==typeof i&&i.startsWith("chat_cancel"),d=void 0!==$;let l="runtime_error";d?l="timeout":a&&(l="abort");const c=formatErrMessage(n);o?await f("cancel"):d?await f("fail","timeout"):await f("fail");try{o?await p.cancel({stopReason:"user_stop"}):d?await p.fail({body:c,stopReason:"timeout"}):await p.fail({body:c||"execution failed"})}catch(e){r.warn?.(`[im] runtime stream terminal write failed requestId=${s} channel=${t.channelId}: ${formatErrMessage(e)}`)}throw r.warn?.(`[im] runtime dispatch failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} cause=${l} dispatchInvoked=${M} error=${c}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,error:c,cause:l}),n}finally{i.unregister(u)}};
1
+ import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";const normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}},deterministicMessageId=(e,n,t,a)=>`${e}_${createHash("sha256").update(`${n}:${t}:${a}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:t,client:a,logger:r,promptRunStore:i}=e;if(!hasRuntimeReplyApi(n))return r.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${t.channelId} in_reply_to=${t.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:t.channelId,inboundMessageId:t.inboundMessageId}),!1;const s=`im_req_${randomUUID().replace(/-/g,"")}`,o=Date.now(),d=e.origin??"im",l={protocol:"im",requestId:s,sessionId:t.sessionKey??"agent:main",sourceMessageId:t.inboundMessageId,chatId:t.channelId,origin:d},c=normalizePromptTimeoutMs(e.promptTimeoutMs),u=promptRunKey(t.channelId,t.inboundMessageId),m=new AbortController;if(!i.register(u,{requestId:s,channelId:t.channelId,abortController:m,startedAt:o}))return r.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${t.inboundMessageId} requestId=${s} channel=${t.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const p=a.messages.startStream({conversationId:t.channelId,id:deterministicMessageId("asst",t.channelId,t.inboundMessageId,t.selfPrincipalId),replyTo:e.inReplyTo??t.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const a="terminal"===n?r.warn:r.debug;a?.(`[im] runtime stream ${n} PATCH error requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${e.message}`)}},{sender:t.selfPrincipalId});let I,g=!1;try{g=!0===(await p.opened()).idempotent}catch(e){r.debug?.(`[im] runtime reply stream open probe failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(g)return r.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:t.channelId,inboundMessageId:t.inboundMessageId}),i.unregister(u),!1;const h=new SerializedCallbackQueue,y=createImReplySink({stream:p,openReasoningStream:()=>(I=a.messages.startStream({conversationId:t.channelId,id:deterministicMessageId("rsn",t.channelId,t.inboundMessageId,t.selfPrincipalId),replyTo:e.inReplyTo??t.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const a="terminal"===n?r.warn:r.debug;a?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${e.message}`)}},{sender:t.selfPrincipalId}),I),abortSignal:m.signal,callbackQueue:h,logger:r,requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,origin:d}),f=async(e,n)=>{if(!I)return!1;try{return"cancel"===e?await I.cancel({stopReason:"user_stop"}):"fail"===e?await I.fail(n?{stopReason:n}:void 0):await I.finalize({stopReason:"end_turn"}),!1}catch(e){return r.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${s} channel=${t.channelId}: ${formatErrMessage(e)}`),!0}};r.info?.(`[im] runtime dispatching requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} origin=${d}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId});let b,$,M=!1;const _=async e=>{y.dispatcher.markComplete(e);try{await y.dispatcher.waitForIdle()}catch{}},w=()=>{if(!m.signal.aborted)return;const e=m.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))};try{const a=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:t.dispatchInput,replyRoute:l,replySinks:{im:y},onDispatchInvoked:()=>{M=!0}}),i=void 0!==c?Promise.race([a,new Promise((e,n)=>{b=setTimeout(()=>{$=new Error(`IM runtime dispatch timed out after ${c}ms`),r.warn?.(`[im] runtime dispatch timeout requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} timeout_ms=${c}`),m.abort($),n($)},c)})]):a,d=await i.finally(()=>{b&&(clearTimeout(b),b=void 0)});w(),await _("success"),w();const u=await f("complete");let I=!1;try{await p.finalize({stopReason:"end_turn"})}catch(e){I=!0,r.warn?.(`[im] runtime reply stream finalize failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId}: ${formatErrMessage(e)}`)}return r.info?.(`[im] runtime dispatch completed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} counts=${JSON.stringify(d.counts)} elapsed_ms=${Date.now()-o}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,counts:d.counts,...I?{terminalWriteFailed:!0}:{},...u?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await _("failure");const a=m.signal.aborted,i=a?m.signal.reason:void 0,o=a&&"string"==typeof i&&i.startsWith("chat_cancel"),d=void 0!==$;let l="runtime_error";d?l="timeout":a&&(l="abort");const c=formatErrMessage(n);o?await f("cancel"):d?await f("fail","timeout"):await f("fail");try{o?await p.cancel({stopReason:"user_stop"}):d?await p.fail({body:c,stopReason:"timeout"}):await p.fail({body:c||"execution failed"})}catch(e){r.warn?.(`[im] runtime stream terminal write failed requestId=${s} channel=${t.channelId}: ${formatErrMessage(e)}`)}throw r.warn?.(`[im] runtime dispatch failed requestId=${s} channel=${t.channelId} in_reply_to=${t.inboundMessageId} cause=${l} dispatchInvoked=${M} error=${c}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:t.channelId,inboundMessageId:t.inboundMessageId,error:c,cause:l}),n}finally{i.unregister(u)}};
@@ -1 +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";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];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(){}replacePart(){}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(){}appendToolResult(){}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={})=>({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,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=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=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("runtime error path: stream is failed with the runtime's error 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("LLM exploded");const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),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",abortController:new AbortController,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,t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
1
+ import{describe,it,expect,beforeEach}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.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={})=>({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,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=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=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("runtime error path: stream is failed with the runtime's error 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("LLM exploded");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",abortController:new AbortController,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,t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
@@ -1 +1 @@
1
- import{randomUUID}from"node:crypto";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=[],a={tool:0,block:0,final:0};let i=!1;const d=(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):!i||(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=>(d("tool_result",()=>{a.tool+=1;const n=s.pop()??`tool_${e.requestId}_${a.tool}`,r=isNonEmptyString(t.text)?t.text:t.mediaUrl??"";e.stream.appendToolResult(n,r,!0===t.isError)}),!0),sendBlockReply:n=>(d("block_reply",()=>{a.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(d("final_reply",()=>{a.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...a}),markComplete:()=>{i=!0}},replyOptions:{abortSignal:e.abortSignal,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=>{d("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=>{d("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const s=t.text;""===o&&""!==r&&(n.appendBody("\n"),r+="\n"),s!==o&&(s.startsWith(o)?(n.appendBody(s.slice(o.length)),o=s):o.startsWith(s)||(n.setBody(r+s),o=s))})},onReasoningEnd:()=>{d("reasoning_end",()=>{""!==o&&(e.stream.appendThinking(o,"done"),r+=o,o="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{d("tool_start",()=>{const n=isNonEmptyString(t.name)?t.name:"tool",r=`tool_${randomUUID()}`;s.push(r),e.stream.appendToolUse(r,n,{},"streaming")})}}}}
1
+ import{resolveRuntimeToolArguments,resolveRuntimeToolCallId,resolveRuntimeToolName}from"./tool-envelope-metadata.js";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=new Map,i=new Map,a=[];let l=0,d=0;const u=(t,n,r)=>{const o=d;return e.stream.appendToolUse(t,n,r,"streaming"),d+=1,o},g=e=>{const t=a.indexOf(e);t>=0&&a.splice(t,1)},m=e=>{const t=e?a.filter(t=>s.get(t)?.name===e):a;return 1===t.length?t[0]:void 0},c=e=>{const t=a.filter(t=>{const n=s.get(t);return!(void 0===n||e&&n.name!==e||n.itemEventObserved)});return 1===t.length?t[0]:void 0},p=e=>a.map(e=>s.get(e)).filter(t=>void 0!==t&&(!e||t.name===e)&&t.itemEventObserved&&!t.toolStartObserved),f=(e,t)=>{i.set(e,t)},h=e=>i.get(e)??e,y=e=>null!=e&&("object"!=typeof e||(Array.isArray(e)?e.length>0:Object.keys(e).length>0)),I=(t,n,r,o)=>{const i=s.get(t);if(i){const s="tool"===i.name&&"tool"!==n?n:i.name,l=!y(i.arguments)&&y(r)?r:i.arguments;return s===i.name&&l===i.arguments||(e.stream.replacePart(i.partIndex,{type:"tool_use",id:i.id,name:s,arguments:l,state:i.state}),i.name=s,i.arguments=l),o&&!a.includes(t)&&"streaming"===i.state&&a.push(t),i}const l={id:t,name:n,arguments:r,partIndex:u(t,n,r),state:"streaming",resultSignatures:new Set,itemEventObserved:!1,toolStartObserved:!1};return s.set(t,l),o&&a.push(t),l},v=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")},$=(t,n,r)=>{const o=`${r?"1":"0"}:${"string"==typeof n?n:JSON.stringify(n)}`;t.resultSignatures.has(o)||(((t,n,r)=>{e.stream.appendToolResult(t,n,r),d+=1})(t.id,n,r),t.resultSignatures.add(o))},b=(e,t,n)=>{const r=i.get(e);if(r)return I(r,t??"tool",n??{},!1);if(s.has(e))return I(e,t??"tool",n??{},!1);const o=m(t);return o?(f(e,o),I(o,t??"tool",n??{},!1)):I(e,t??"tool",n??{},!1)},x={tool:0,block:0,final:0};let S=!1;const k=(t,n)=>{e.callbackQueue.enqueueSync(()=>{if((t=>e.abortSignal.aborted?(e.logger.debug?.(`[im-sink] ${t} ignored after abort requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):e.stream.isTerminated?(e.logger.debug?.(`[im-sink] ${t} ignored after stream_terminated requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):!S||(e.logger.debug?.(`[im-sink] ${t} ignored after mark_complete requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1))(t))try{n()}catch(n){const r=n instanceof Error?n.message:String(n);e.logger.warn?.(`[im-sink] ${t} mapping failed requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId} error=${r}`)}})};return{dispatcher:{sendToolResult:t=>(k("tool_result",()=>{const n=resolveRuntimeToolCallId(t),r=resolveRuntimeToolName(t),o=n?void 0:r?m(r):m();if(!n&&!o)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 i=n?b(n,r,resolveRuntimeToolArguments(t)):I(o,r??s.get(o)?.name??"tool",resolveRuntimeToolArguments(t),!1);x.tool+=1,v(i),$(i,(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),g(i.id)}),!0),sendBlockReply:n=>(k("block_reply",()=>{x.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(k("final_reply",()=>{x.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...x}),markComplete:t=>{for(const n of s.values())try{"success"===t&&v(n),g(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}`)}S=!0}},replyOptions:{abortSignal:e.abortSignal,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=>{k("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=>{k("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const s=t.text;""===o&&""!==r&&(n.appendBody("\n"),r+="\n"),s!==o&&(s.startsWith(o)?(n.appendBody(s.slice(o.length)),o=s):o.startsWith(s)||(n.setBody(r+s),o=s))})},onReasoningEnd:()=>{k("reasoning_end",()=>{var t;""!==o&&(t=o,e.stream.appendThinking(t,"done"),d+=1,r+=o,o="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{k("tool_start",()=>{const n="string"==typeof t.phase?t.phase:"start",r=resolveRuntimeToolName(t)??"tool",o=resolveRuntimeToolCallId(t);if("update"===n){if(o){const e=h(o);s.has(e)&&I(e,r,resolveRuntimeToolArguments(t),!0)}return}const i=resolveRuntimeToolArguments(t);if(o){const e=b(o,r,i);return e.toolStartObserved=!0,void(a.includes(e.id)||"streaming"!==e.state||a.push(e.id))}const d=p(r),u=d.length>0?d:p();if(1===u.length){return void(I(u[0].id,r,i,!0).toolStartObserved=!0)}if(u.length>1)return void e.logger.debug?.(`[im-sink] ambiguous id-less tool start ignored requestId=${e.requestId} channel=${e.channelId} tool=${r} candidates=${u.length}`);const g=c(r);if(g){return void(I(g,r,i,!0).toolStartObserved=!0)}I((l+=1,`tool_${e.requestId}_${l}`),r,i,!0).toolStartObserved=!0})},onItemEvent:e=>{k("item_event",()=>{const t="string"==typeof e.kind?e.kind.toLowerCase():"";if(t&&"tool"!==t)return;const n="string"==typeof e.phase?e.phase.toLowerCase():"update",r=resolveRuntimeToolCallId(e)??("string"==typeof e.itemId&&e.itemId.trim()?e.itemId.trim():void 0),o=resolveRuntimeToolName(e)??("string"==typeof e.title&&e.title.trim()?e.title.trim():void 0);if(!r||!o)return;if("update"===n){const e=h(r),t=s.get(e);return void(t&&(I(t.id,o,{},!0).itemEventObserved=!0))}const l=((e,t)=>{const n=i.get(e);if(n)return I(n,t,{},!1);if(s.has(e))return I(e,t,{},!1);const r=c(t);return r?(f(e,r),I(r,t,{},!1)):I(e,t,{},!1)})(r,o);if(l.itemEventObserved=!0,a.includes(l.id)||"streaming"!==l.state||a.push(l.id),"end"!==n)return;const d="string"==typeof e.status?e.status.toLowerCase():"",u="failed"===d||"error"===d||"cancelled"===d,m=isNonEmptyString(e.summary)?e.summary:isNonEmptyString(e.meta)?e.meta:"";v(l),m&&$(l,m,u),g(l.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,n="streaming"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:n}),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,n)=>({stream:e,openReasoningStream:n?()=>n: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,n;beforeEach(()=>{e=new FakeMessageStream,t=new FakeMessageStream,a=new AbortController,n=new SerializedCallbackQueue}),it("onPartialReply: extracts delta from cumulative snapshot and appendBody's the suffix",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"Hello"}),t.replyOptions.onPartialReply({text:"Hello, world"}),t.replyOptions.onPartialReply({text:"Hello, world!"}),await n.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,n));t.replyOptions.onPartialReply({text:"abcdef"}),t.replyOptions.onPartialReply({text:"abc"}),t.replyOptions.onPartialReply({text:"abcdefg"}),await n.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,n));t.replyOptions.onPartialReply({text:"alpha"}),t.replyOptions.onPartialReply({text:"beta"}),await n.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,n));t.replyOptions.onPartialReply({text:"partial..."}),t.dispatcher.sendFinalReply({text:"canonical final body"}),await n.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,n));t.dispatcher.sendBlockReply({text:"block contents"}),await n.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 o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onReasoningStream({text:"thinking step 1"}),o.replyOptions.onReasoningStream({text:"thinking step 1 and 2"}),o.replyOptions.onReasoningEnd(),await n.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 o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onReasoningStream({text:"first"}),o.replyOptions.onReasoningEnd(),o.replyOptions.onReasoningStream({text:"second"}),o.replyOptions.onReasoningEnd(),await n.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 o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onReasoningStream({text:"alpha"}),o.replyOptions.onReasoningStream({text:"beta gamma"}),await n.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,n));t.replyOptions.onReasoningStream({text:"ignored"}),t.replyOptions.onReasoningEnd(),await n.waitForIdle(),expect(e.calls).toEqual([])}),it("onToolStart followed by sendToolResult: associates them via stack-popped tool_use_id",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onToolStart({name:"calc"}),t.dispatcher.sendToolResult({text:"42"}),await n.waitForIdle();const o=e.calls[0],s=e.calls[1];expect(o.type).toBe("appendToolUse"),expect(s.type).toBe("appendToolResult"),"appendToolUse"===o.type&&"appendToolResult"===s.type&&(expect(s.toolUseId).toBe(o.id),expect(s.content).toBe("42")),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:1,block:0,final:0})}),it("sendToolResult without a preceding onToolStart: synthesizes a tool_use_id",async()=>{createImReplySink(buildDeps(e,a.signal,n)).dispatcher.sendToolResult({text:"orphan",isError:!0}),await n.waitForIdle(),expect(e.calls).toHaveLength(1);const t=e.calls[0];expect(t.type).toBe("appendToolResult"),"appendToolResult"===t.type&&(expect(t.toolUseId).toMatch(/^tool_im_req_test_1$/),expect(t.content).toBe("orphan"),expect(t.isError).toBe(!0))}),it("preserves callback order across interleaved partial / reasoning / tool events",async()=>{const o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onPartialReply({text:"A"}),o.replyOptions.onReasoningStream({text:"think 1"}),o.replyOptions.onPartialReply({text:"AB"}),o.replyOptions.onToolStart({name:"tool1"}),o.dispatcher.sendToolResult({text:"result1"}),o.replyOptions.onPartialReply({text:"ABC"}),o.replyOptions.onReasoningEnd(),o.dispatcher.sendFinalReply({text:"ABC final"}),await n.waitForIdle();const s=e.calls.map(e=>e.type);expect(s).toEqual(["appendBody","appendBody","appendToolUse","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,n));t.replyOptions.onPartialReply({text:"before"}),await n.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 n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"before"}])}),it("markComplete suppresses subsequent callbacks (idempotent terminal)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"live"}),t.dispatcher.markComplete(),t.replyOptions.onPartialReply({text:"live-after"}),t.dispatcher.sendFinalReply({text:"after-complete"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"live"}])}),it("getQueuedCounts returns a snapshot copy (mutation does not leak)",()=>{const t=createImReplySink(buildDeps(e,a.signal,n));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,n));e.isTerminated=!0,t.replyOptions.onPartialReply({text:"dropped"}),t.dispatcher.sendFinalReply({text:"dropped"}),await n.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,n),o=createImReplySink(t),s=e.appendBody.bind(e);let l=!0;e.appendBody=e=>{if(l)throw l=!1,new Error("synthetic appendBody failure");return s(e)},o.replyOptions.onPartialReply({text:"trigger"}),o.replyOptions.onPartialReply({text:"trigger ok"}),await n.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.filter(e=>"replacePart"===e.type)).toHaveLength(2)}),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("coalesces repeated id-less start callbacks for one pending tool",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.filter(e=>"appendToolUse"===e.type)).toEqual([{type:"appendToolUse",id:"tool_im_req_test_1",name:"exec",args:{command:"long task"}}]),expect(e.calls.filter(e=>"appendToolResult"===e.type)).toEqual([{type:"appendToolResult",toolUseId:"tool_im_req_test_1",content:"done"}])}),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.filter(e=>"appendToolUse"===e.type)).toHaveLength(1),expect(e.calls.filter(e=>"appendToolResult"===e.type)).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("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:"tool_im_req_test_2",content:"process done"},{type:"appendToolResult",toolUseId:"tool_im_req_test_1",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:"tool_im_req_test_1",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 +1 @@
1
- import{describe,it,expect,vi}from"vitest";import{hasRuntimeReplyApi,buildFinalizedMsgContext,resolveReplySinkForRoute,dispatchRuntimeReplyWithRoute}from"./reply-router.js";const noopSink=()=>({dispatcher:{sendToolResult:()=>!0,sendBlockReply:()=>!0,sendFinalReply:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:()=>{}},replyOptions:void 0}),okResult={counts:{tool:0,block:0,final:1}},baseRoute=(e={})=>({protocol:"im",requestId:"req_test_001",sessionId:"agent:main:test-session",...e}),basePrompt=(e={})=>({message:"hello world",agentMessage:"hello world",...e});describe("hasRuntimeReplyApi",()=>{it("returns true when runtime exposes a callable dispatchReplyFromConfig",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}})).toBe(!0)}),it("returns false when runtime is undefined",()=>{expect(hasRuntimeReplyApi(void 0)).toBe(!1)}),it("returns false when channel.reply is missing",()=>{expect(hasRuntimeReplyApi({})).toBe(!1),expect(hasRuntimeReplyApi({channel:{}})).toBe(!1),expect(hasRuntimeReplyApi({channel:{reply:{}}})).toBe(!1)}),it("returns false when dispatchReplyFromConfig is present but not a function",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:"not-a-function"}}})).toBe(!1)})}),describe("buildFinalizedMsgContext",()=>{it("uses beeos-claw defaults when route doesn't override",()=>{const e=buildFinalizedMsgContext(basePrompt(),baseRoute());expect(e).toEqual({Body:"hello world",BodyForAgent:"hello world",From:"beeos-claw:main",To:"main",SessionKey:"agent:main:test-session",Surface:"beeos-claw",Provider:"beeos-claw",OriginatingChannel:"beeos-claw",OriginatingTo:"main",AccountId:"main",CommandAuthorized:!0})}),it("honors caller-supplied originatingChannel / originatingTo / accountId",()=>{const e=buildFinalizedMsgContext(basePrompt({message:"hi",agentMessage:"agent-hi"}),baseRoute({originatingChannel:"custom-channel",originatingTo:"thread-42",accountId:"user-7"}));expect(e.From).toBe("custom-channel:thread-42"),expect(e.To).toBe("thread-42"),expect(e.OriginatingChannel).toBe("custom-channel"),expect(e.OriginatingTo).toBe("thread-42"),expect(e.AccountId).toBe("user-7"),expect(e.BodyForAgent).toBe("agent-hi")}),it("accepts both 'acp' and 'im' protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"acp"}))).not.toThrow(),expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"im"}))).not.toThrow()}),it("rejects unsupported protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"websocket"}))).toThrow(/unsupported reply route protocol=websocket/)})}),describe("resolveReplySinkForRoute",()=>{it("returns the matching protocol sink",()=>{const e=noopSink(),t=noopSink(),o={acp:e,im:t};expect(resolveReplySinkForRoute(baseRoute({protocol:"acp"}),o)).toBe(e),expect(resolveReplySinkForRoute(baseRoute({protocol:"im"}),o)).toBe(t)}),it("throws when the matching sink is missing",()=>{const e={acp:noopSink()};expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"im",requestId:"req_missing"}),e)).toThrow(/runtime reply sink unavailable protocol=im requestId=req_missing/)}),it("rejects unsupported protocols",()=>{expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"xyz"}),{im:noopSink()})).toThrow(/unsupported reply route protocol=xyz/)})}),describe("dispatchRuntimeReplyWithRoute",()=>{it("dispatches via the matching sink and returns the runtime result",async()=>{const e=noopSink(),t=[],o={channel:{reply:{dispatchReplyFromConfig:async e=>(t.push(e),okResult)}}},n=await dispatchRuntimeReplyWithRoute({runtime:o,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt({message:"test prompt"}),replyRoute:baseRoute({protocol:"im",requestId:"req_ok"}),replySinks:{im:e}});expect(n).toBe(okResult),expect(t).toHaveLength(1);const i=t[0];expect(i.ctx.Body).toBe("test prompt"),expect(i.ctx.OriginatingChannel).toBe("beeos-claw"),expect(i.dispatcher).toBe(e.dispatcher),expect(i.cfg).toEqual({fromPluginConfig:!0})}),it("prefers runtime.config.loadConfig() over pluginConfig",async()=>{const e=[],t={config:{loadConfig:()=>({fromRuntime:!0})},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromRuntime:!0})}),it("falls back to pluginConfig when runtime.config.loadConfig throws",async()=>{const e=[],t={config:{loadConfig:()=>{throw new Error("load failed")}},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromPluginConfig:!0})}),it("calls onDispatchInvoked exactly once, before awaiting the dispatch promise",async()=>{const e=vi.fn();let t=!1;const o={channel:{reply:{dispatchReplyFromConfig:vi.fn(async()=>(expect(e).toHaveBeenCalledTimes(1),t=!0,okResult))}}};await dispatchRuntimeReplyWithRoute({runtime:o,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()},onDispatchInvoked:e}),expect(e).toHaveBeenCalledTimes(1),expect(t).toBe(!0)}),it("throws when runtime reply API is missing",async()=>{await expect(dispatchRuntimeReplyWithRoute({runtime:{},preparedPrompt:basePrompt(),replyRoute:baseRoute({requestId:"req_no_api"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply API unavailable protocol=im requestId=req_no_api/)}),it("propagates runtime dispatch errors verbatim",async()=>{const e=new Error("LLM exploded"),t={channel:{reply:{dispatchReplyFromConfig:async()=>{throw e}}}};await expect(dispatchRuntimeReplyWithRoute({runtime:t,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}})).rejects.toBe(e)}),it("throws when route.protocol has no matching sink",async()=>{const e={channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}};await expect(dispatchRuntimeReplyWithRoute({runtime:e,preparedPrompt:basePrompt(),replyRoute:baseRoute({protocol:"acp",requestId:"req_no_sink"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply sink unavailable protocol=acp requestId=req_no_sink/)})});
1
+ import{describe,it,expect,vi}from"vitest";import{hasRuntimeReplyApi,buildFinalizedMsgContext,resolveReplySinkForRoute,dispatchRuntimeReplyWithRoute}from"./reply-router.js";const noopSink=()=>({dispatcher:{sendToolResult:()=>!0,sendBlockReply:()=>!0,sendFinalReply:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:e=>{}},replyOptions:void 0}),okResult={counts:{tool:0,block:0,final:1}},baseRoute=(e={})=>({protocol:"im",requestId:"req_test_001",sessionId:"agent:main:test-session",...e}),basePrompt=(e={})=>({message:"hello world",agentMessage:"hello world",...e});describe("hasRuntimeReplyApi",()=>{it("returns true when runtime exposes a callable dispatchReplyFromConfig",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}})).toBe(!0)}),it("returns false when runtime is undefined",()=>{expect(hasRuntimeReplyApi(void 0)).toBe(!1)}),it("returns false when channel.reply is missing",()=>{expect(hasRuntimeReplyApi({})).toBe(!1),expect(hasRuntimeReplyApi({channel:{}})).toBe(!1),expect(hasRuntimeReplyApi({channel:{reply:{}}})).toBe(!1)}),it("returns false when dispatchReplyFromConfig is present but not a function",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:"not-a-function"}}})).toBe(!1)})}),describe("buildFinalizedMsgContext",()=>{it("uses beeos-claw defaults when route doesn't override",()=>{const e=buildFinalizedMsgContext(basePrompt(),baseRoute());expect(e).toEqual({Body:"hello world",BodyForAgent:"hello world",From:"beeos-claw:main",To:"main",SessionKey:"agent:main:test-session",Surface:"beeos-claw",Provider:"beeos-claw",OriginatingChannel:"beeos-claw",OriginatingTo:"main",AccountId:"main",CommandAuthorized:!0})}),it("honors caller-supplied originatingChannel / originatingTo / accountId",()=>{const e=buildFinalizedMsgContext(basePrompt({message:"hi",agentMessage:"agent-hi"}),baseRoute({originatingChannel:"custom-channel",originatingTo:"thread-42",accountId:"user-7"}));expect(e.From).toBe("custom-channel:thread-42"),expect(e.To).toBe("thread-42"),expect(e.OriginatingChannel).toBe("custom-channel"),expect(e.OriginatingTo).toBe("thread-42"),expect(e.AccountId).toBe("user-7"),expect(e.BodyForAgent).toBe("agent-hi")}),it("accepts both 'acp' and 'im' protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"acp"}))).not.toThrow(),expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"im"}))).not.toThrow()}),it("rejects unsupported protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"websocket"}))).toThrow(/unsupported reply route protocol=websocket/)})}),describe("resolveReplySinkForRoute",()=>{it("returns the matching protocol sink",()=>{const e=noopSink(),t=noopSink(),o={acp:e,im:t};expect(resolveReplySinkForRoute(baseRoute({protocol:"acp"}),o)).toBe(e),expect(resolveReplySinkForRoute(baseRoute({protocol:"im"}),o)).toBe(t)}),it("throws when the matching sink is missing",()=>{const e={acp:noopSink()};expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"im",requestId:"req_missing"}),e)).toThrow(/runtime reply sink unavailable protocol=im requestId=req_missing/)}),it("rejects unsupported protocols",()=>{expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"xyz"}),{im:noopSink()})).toThrow(/unsupported reply route protocol=xyz/)})}),describe("dispatchRuntimeReplyWithRoute",()=>{it("dispatches via the matching sink and returns the runtime result",async()=>{const e=noopSink(),t=[],o={channel:{reply:{dispatchReplyFromConfig:async e=>(t.push(e),okResult)}}},n=await dispatchRuntimeReplyWithRoute({runtime:o,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt({message:"test prompt"}),replyRoute:baseRoute({protocol:"im",requestId:"req_ok"}),replySinks:{im:e}});expect(n).toBe(okResult),expect(t).toHaveLength(1);const i=t[0];expect(i.ctx.Body).toBe("test prompt"),expect(i.ctx.OriginatingChannel).toBe("beeos-claw"),expect(i.dispatcher).toBe(e.dispatcher),expect(i.cfg).toEqual({fromPluginConfig:!0})}),it("prefers runtime.config.loadConfig() over pluginConfig",async()=>{const e=[],t={config:{loadConfig:()=>({fromRuntime:!0})},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromRuntime:!0})}),it("falls back to pluginConfig when runtime.config.loadConfig throws",async()=>{const e=[],t={config:{loadConfig:()=>{throw new Error("load failed")}},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromPluginConfig:!0})}),it("calls onDispatchInvoked exactly once, before awaiting the dispatch promise",async()=>{const e=vi.fn();let t=!1;const o={channel:{reply:{dispatchReplyFromConfig:vi.fn(async()=>(expect(e).toHaveBeenCalledTimes(1),t=!0,okResult))}}};await dispatchRuntimeReplyWithRoute({runtime:o,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()},onDispatchInvoked:e}),expect(e).toHaveBeenCalledTimes(1),expect(t).toBe(!0)}),it("throws when runtime reply API is missing",async()=>{await expect(dispatchRuntimeReplyWithRoute({runtime:{},preparedPrompt:basePrompt(),replyRoute:baseRoute({requestId:"req_no_api"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply API unavailable protocol=im requestId=req_no_api/)}),it("propagates runtime dispatch errors verbatim",async()=>{const e=new Error("LLM exploded"),t={channel:{reply:{dispatchReplyFromConfig:async()=>{throw e}}}};await expect(dispatchRuntimeReplyWithRoute({runtime:t,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}})).rejects.toBe(e)}),it("throws when route.protocol has no matching sink",async()=>{const e={channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}};await expect(dispatchRuntimeReplyWithRoute({runtime:e,preparedPrompt:basePrompt(),replyRoute:baseRoute({protocol:"acp",requestId:"req_no_sink"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply sink unavailable protocol=acp requestId=req_no_sink/)})});
@@ -0,0 +1 @@
1
+ const isRecord=t=>"object"==typeof t&&null!==t&&!Array.isArray(t),asNonEmptyString=t=>{if("string"!=typeof t)return;const o=t.trim();return o.length>0?o:void 0},metadataScopes=t=>{if(!isRecord(t))return[];const o=[t],n=isRecord(t.channelData)?t.channelData:void 0,r=n&&isRecord(n.im)?n.im:void 0;return r&&o.push(r),n&&o.push(n),o};export const resolveRuntimeToolCallId=t=>{for(const o of metadataScopes(t)){const t=asNonEmptyString(o.toolCallId)??asNonEmptyString(o.tool_call_id);if(t)return t}};export const resolveRuntimeToolName=t=>{for(const o of metadataScopes(t)){const t=asNonEmptyString(o.name)??asNonEmptyString(o.toolName)??asNonEmptyString(o.tool_name);if(t)return t}};export const resolveRuntimeToolArguments=t=>{for(const o of metadataScopes(t)){if(void 0!==o.arguments)return o.arguments;if(void 0!==o.args)return o.args;if(void 0!==o.input)return o.input}return{}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beeos-claw",
3
- "version": "0.5.49",
3
+ "version": "0.5.51",
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>",