beeos-claw 0.5.59 → 0.5.60
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";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{SESSION_RECOVERY_REQUIRED_CODE,SESSION_RECOVERY_REQUIRED_MESSAGE}from"./runtime/session-mutation-coordinator.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.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,r,
|
|
1
|
+
import{createHash,randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";import{promptRunKey}from"./runtime/prompt-run-store.js";import{classifyAgentRuntimeFailure}from"./runtime/agent-runtime-failure-classifier.js";import{SESSION_RECOVERY_REQUIRED_CODE,SESSION_RECOVERY_REQUIRED_MESSAGE}from"./runtime/session-mutation-coordinator.js";import{interceptAgentRuntimeFailures,messageStopReasonForRuntimeFailure,SESSION_BUSY_PUBLIC_MESSAGE}from"./runtime/im-runtime-failure-policy.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,r,t)=>`${e}_${createHash("sha256").update(`${n}:${r}:${t}`).digest("hex").slice(0,32)}`;export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:r,client:t,logger:a,promptRunStore:i,sessionMutationCoordinator:s,abortTransport:o}=e;if(!hasRuntimeReplyApi(n))return a.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),!1;const d=`im_req_${randomUUID().replace(/-/g,"")}`,c=Date.now(),l=e.origin??"im",u={protocol:"im",requestId:d,sessionId:r.sessionKey??"agent:main",sourceMessageId:r.inboundMessageId,chatId:r.channelId,origin:l},m=normalizePromptTimeoutMs(e.promptTimeoutMs),p=promptRunKey(r.channelId,r.inboundMessageId),I=r.sessionKey??"agent:main",h=new AbortController;let g,y,_;const f=new Promise((e,n)=>{_=n}),E=async e=>{if(y??=e,!g)return h.abort(e),{ok:!0};const n=await s.requestCancel(g,o,()=>h.abort(e));return n.ok?n.terminalObservation.then(e=>{e.ok||_(new Error(SESSION_RECOVERY_REQUIRED_CODE))}):_(new Error(SESSION_RECOVERY_REQUIRED_CODE)),{ok:n.ok}};if(!i.register(p,{requestId:d,channelId:r.channelId,requestCancel:E,startedAt:c,sessionKey:I}))return a.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${r.inboundMessageId} requestId=${d} channel=${r.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const b=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("asst",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId});let $,R=!1;try{R=!0===(await b.opened()).idempotent}catch(e){a.debug?.(`[im] runtime reply stream open probe failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}; continuing so terminal fallback can land an error row`)}if(R)return a.info?.(`[im] runtime dispatch skipped existing deterministic reply requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"idempotent_reply_exists",channelId:r.channelId,inboundMessageId:r.inboundMessageId}),i.unregister(p),!1;const w=new SerializedCallbackQueue,M=createImReplySink({stream:b,openReasoningStream:()=>($=t.messages.startStream({conversationId:r.channelId,id:deterministicMessageId("rsn",r.channelId,r.inboundMessageId,r.selfPrincipalId),replyTo:e.inReplyTo??r.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const t="terminal"===n?a.warn:a.debug;t?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${e.message}`)}},{sender:r.selfPrincipalId}),$),abortSignal:h.signal,callbackQueue:w,logger:a,requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,origin:l,onAgentRunStart:e=>{s.bindRun(g,e)}}),S=interceptAgentRuntimeFailures(M,"im_runtime",e=>a.warn?.(`[im] runtime failure reply suppressed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} error_code=${e.errorCode}`)),v=S.sink,q=async(e,n)=>{if(!$)return!1;try{return"cancel"===e?await $.cancel({stopReason:"user_stop"}):"fail"===e?await $.fail(n?{stopReason:n}:void 0):await $.finalize({stopReason:"end_turn"}),!1}catch(e){return a.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`),!0}};let C,k,O=!1;const D=async e=>{v.dispatcher.markComplete(e);try{await v.dispatcher.waitForIdle()}catch{}},A=()=>{if(!h.signal.aborted)return;const e=h.signal.reason;throw e instanceof Error?e:new Error(String(e??"runtime dispatch aborted"))};let U,T;const F=async e=>{if(!g)throw new Error("session_mutation_not_acquired");const n=await s.converge(g,e);return n.ok?(U=n.permit,!0):(a.error?.(`[im] session mutation recovery required requestId=${d} sessionKey=${I} reason=${n.recovery.reason}`),T={code:n.recovery.code,reason:n.recovery.reason,sessionKey:n.recovery.sessionKey,operationId:n.recovery.operationId,surface:"message_service",...n.recovery.terminalKind?{terminalKind:n.recovery.terminalKind}:{}},!1)};try{A();const t=s.tryStartInteractive({sessionKey:I,label:"im_chat_message"},"message_service");if(!t.ok){try{await b.fail({body:SESSION_BUSY_PUBLIC_MESSAGE,stopReason:"error"})}catch(e){a.warn?.(`[im] session busy terminal write failed channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.warn?.(`[im] session write gate busy sessionKey=${I} channel=${r.channelId} in_reply_to=${r.inboundMessageId} active_operation=${t.activeOperationId??"unknown"}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:"session_busy",cause:"runtime_error"}),!1}g=t.token,A(),a.info?.(`[im] runtime dispatching requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} origin=${l}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId});const o=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:r.dispatchInput,replyRoute:u,replySinks:{im:v},onDispatchInvoked:()=>{O=!0}});void 0!==m&&(C=setTimeout(()=>{k=new Error(`IM runtime dispatch timed out after ${m}ms`),a.warn?.(`[im] runtime dispatch timeout requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} timeout_ms=${m}`),E(k)},m));const h=Promise.race([o,f]),y=await h.finally(()=>{C&&(clearTimeout(C),C=void 0)}),_=S.detectedFailure();if(_)throw new Error(_.diagnosticMessage);if(A(),await D("success"),A(),!i.claimTerminal(p,"completion")){if(A(),"cancelling"===i.phase(p))throw new Error("cancellation in progress");throw new Error("terminal outcome already claimed")}if(!await F("completed"))return await q("fail"),await b.fail({body:SESSION_RECOVERY_REQUIRED_MESSAGE,stopReason:"error"}).catch(()=>{}),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:SESSION_RECOVERY_REQUIRED_CODE,cause:"runtime_error",...T?{recovery:T}:{}}),!1;const $=await q("complete");let R=!1;try{await b.finalize({stopReason:"end_turn"})}catch(e){R=!0,a.warn?.(`[im] runtime reply stream finalize failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId}: ${formatErrMessage(e)}`)}return a.info?.(`[im] runtime dispatch completed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} counts=${JSON.stringify(y.counts)} elapsed_ms=${Date.now()-c}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,counts:y.counts,...R?{terminalWriteFailed:!0}:{},...$?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await D("failure");const t=h.signal.aborted,s=t?h.signal.reason:void 0,o="cancelling"===i.phase(p),c=void 0!==k,l=s??y,u=!c&&"string"==typeof l&&l.startsWith("chat_cancel");let m="runtime_error";c?m="timeout":(t||o)&&(m="abort");const I=o||u||c||t?"cancellation":"completion";if(!i.claimTerminal(p,I))throw a.warn?.(`[im] terminal write skipped because another outcome already committed requestId=${d} channel=${r.channelId}`),n;const _=u?"cancelled":c?"timeout":"error";if(g&&!await F(_))throw await q("fail"),await b.fail({body:SESSION_RECOVERY_REQUIRED_MESSAGE,stopReason:"error"}).catch(()=>{}),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:SESSION_RECOVERY_REQUIRED_CODE,cause:m,...T?{recovery:T}:{}}),n;const f=classifyAgentRuntimeFailure({raw:n,source:"im_runtime",runtimeKind:"openclaw",requestAborted:u}),E=u?f:S.detectedFailure()??f;u?await q("cancel"):c?await q("fail","timeout"):await q("fail");try{u?await b.cancel({stopReason:"user_stop"}):await b.fail({body:E.publicMessage,stopReason:messageStopReasonForRuntimeFailure(E)})}catch(e){a.warn?.(`[im] runtime stream terminal write failed requestId=${d} channel=${r.channelId}: ${formatErrMessage(e)}`)}throw a.warn?.(`[im] runtime dispatch failed requestId=${d} channel=${r.channelId} in_reply_to=${r.inboundMessageId} cause=${m} dispatchInvoked=${O} error_code=${E.errorCode}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:d,channelId:r.channelId,inboundMessageId:r.inboundMessageId,error:E.errorCode,cause:m}),n}finally{i.unregister(p),O?U&&s.release(U):s.releaseBeforeRuntime(g)}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore,promptRunKey}from"./runtime/prompt-run-store.js";import{SessionWriteGate}from"./acp-gateway/session-write-gate.js";import{SessionMutationCoordinator}from"./runtime/session-mutation-coordinator.js";class FakeMessageStream{openEnvelope;body="";isTerminated=!1;thinking=[];parts=[];failNextTerminal=!1;finalized=null;constructor(e){this.openEnvelope=e}async opened(){return this.openEnvelope}appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(e){this.parts.push(e)}replacePart(e,t){this.parts[e]=t}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(e,t,a,s="streaming"){this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:s})}appendToolResult(e,t,a=!1){this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}})}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];nextOpenIdempotent=!1;messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream({id:e.id??"env_fake",state:"streaming",idempotent:this.nextOpenIdempotent});return this.nextOpenIdempotent=!1,this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const a=t.replyOptions?.abortSignal;if(!a)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:a})}}}}),stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,a,s,n={})=>{const o=new SessionWriteGate,r=n.sessionMutationCoordinator??new SessionMutationCoordinator(o,{waitForQuiescence:async()=>({quiescent:!0,reason:"unlocked",elapsedMs:0})},{});return{runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:a,sessionMutationCoordinator:r,abortTransport:n.abortTransport??{chatAbort:async()=>{}},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()})});
|
|
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()=>{const s=stubLogger();let n;const o=new Promise(e=>{n=e}),r=makeRuntime(async({abortSignal:e})=>(n(),await new Promise((t,a)=>{e.addEventListener("abort",()=>{const t=e.reason;a(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),i=dispatchImInboundPrompt(baseArgs(r,e,t,a,{logger:s}));await o;const c=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request");expect(c.ok).toBe(!0),await expect(i).rejects.toThrow(/chat_cancel:user_request/);const p=e.streams[0];expect(p.finalized?.state).toBe("cancelled"),expect(p.finalized?.stopReason).toBe("user_stop");const l=a.find(e=>"dispatch_error"===e.type);expect(l).toMatchObject({cause:"abort",error:"request_aborted"}),expect(s.lines).toContainEqual(expect.stringContaining("cause=abort dispatchInvoked=true error_code=request_aborted")),expect(t.size()).toBe(0)}),it("chat_cancel path: a runtime that resolves after abort still cancels the stream",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise(t=>{e.addEventListener("abort",()=>t())}),{counts:{tool:1,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_stop");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_stop/),expect(e.streams[0].finalized).toMatchObject({state:"cancelled",stopReason:"user_stop"}),expect(a.find(e=>"dispatch_complete"===e.type)).toBeUndefined(),expect(a.find(e=>"dispatch_error"===e.type)).toMatchObject({cause:"abort",error:"request_aborted"}),expect(t.size()).toBe(0)}),it("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",error:"provider_error"}),expect(t.size()).toBe(0)}),it("runtime failure leaves an unfinished tool non-successful",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onToolStart?.({name:"browser",phase:"start"}),new Error("browser crashed")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/browser crashed/),expect(e.streams[0].parts).toEqual([{type:"tool_use",id:expect.stringMatching(/^tool_im_req_/),name:"browser",arguments:{},state:"streaming"}]),expect(e.streams[0].finalized?.state).toBe("failed")}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let s=-1;const n=makeRuntime(async()=>(s=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,a)),expect(s).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register(promptRunKey("ch_t1","msg_t1"),{requestId:"im_req_existing",channelId:"ch_t1",requestCancel:async()=>({ok:!0}),startedAt:Date.now()});const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const s=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),s(),await new Promise((e,a)=>{t.addEventListener("abort",()=>{const e=t.reason;a(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n,await t.abort(promptRunKey("ch_t1","msg_t1"),"chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{formatFriendlyError}from"../errors/classify.js";export const INTERRUPTED_PUBLIC_MESSAGE="The previous run was interrupted before it completed.";export const TIMEOUT_PUBLIC_MESSAGE="The agent task timed out. Please retry.";export const GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE="The agent could not complete the request. Please retry.";const OPENCLAW_SESSION_LOCK_PATTERNS=[/^\s*(?:⚠️\s*)?agent failed before reply:\s*session file locked\b/i,/^\s*sessionwritelocktimeouterror\b/i,/^\s*OPENCLAW_SESSION_WRITE_LOCK\b/i],ABORT_PATTERNS=[/^\s*request was aborted\.?\s*$/i,/^\s*this operation was aborted\.?\s*$/i,/^\s*aborterror\b/i,/^\s*agent failed before reply:\s*.*\baborted\b/i],AGENT_LOST_PATTERNS=[/^\s*(?:agent failed before reply:\s*)?gateway disconnected\b/i,/^\s*agent lost\b/i,/^\s*no live owner\b/i,/^\s*stale live stream\b/i,/^\s*bridge_not_ready\b/i,/^\s*gateway not connected\b/i],TIMEOUT_PATTERNS=[/^\s*prompt timed out\b/i,/^\s*im runtime dispatch timed out\b/i,/^\s*gateway\b.*\btimed out\b/i,/^\s*deadline exceeded\b/i];function matchesAny(e,r){return r.some(r=>r.test(e))}function extractLockDiagnostics(e){const r=e.match(/\bpid=(\d+)\b/i)?.[1],t=e.match(/([^\s]+\.jsonl\.lock)\b/)?.[1];return{...r?{lockPid:r}:{},...t?{lockPath:t}:{}}}function asString(e){if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}}export function classifyAgentRuntimeFailure(e){return detectAgentRuntimeFailure(e)??classifyProviderRuntimeFailure(e)}export function detectAgentRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim();return matchesAny(t,OPENCLAW_SESSION_LOCK_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",diagnosticMessage:t,source:e.source,runtimeKind:r,...extractLockDiagnostics(t)}:matchesAny(t,ABORT_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,AGENT_LOST_PATTERNS)?{kind:"interrupted",stopReason:"agent_lost",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"agent_lost",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,TIMEOUT_PATTERNS)?{kind:"interrupted",stopReason:"timeout",publicMessage:TIMEOUT_PUBLIC_MESSAGE,errorCode:"gateway_timeout",diagnosticMessage:t,source:e.source,runtimeKind:r}:void 0}function classifyProviderRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim(),i=t.toLowerCase(),s=formatFriendlyError(t);return{kind:"error",stopReason:"error",publicMessage:s&&s!==t?s:GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE,errorCode:i?"provider_error":"unknown_runtime_error",diagnosticMessage:t,source:e.source,runtimeKind:r}}export function isRuntimeFailureInterrupted(e){return"interrupted"===e.kind}
|
|
1
|
+
import{formatFriendlyError}from"../errors/classify.js";export const INTERRUPTED_PUBLIC_MESSAGE="The previous run was interrupted before it completed.";export const TIMEOUT_PUBLIC_MESSAGE="The agent task timed out. Please retry.";export const GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE="The agent could not complete the request. Please retry.";const OPENCLAW_SESSION_LOCK_PATTERNS=[/^\s*(?:⚠️\s*)?agent failed before reply:\s*session file locked\b/i,/^\s*sessionwritelocktimeouterror\b/i,/^\s*OPENCLAW_SESSION_WRITE_LOCK\b/i],ABORT_PATTERNS=[/^\s*request was aborted\.?\s*$/i,/^\s*this operation was aborted\.?\s*$/i,/^\s*aborterror\b/i,/^\s*agent failed before reply:\s*.*\baborted\b/i],AGENT_LOST_PATTERNS=[/^\s*(?:agent failed before reply:\s*)?gateway disconnected\b/i,/^\s*agent lost\b/i,/^\s*no live owner\b/i,/^\s*stale live stream\b/i,/^\s*bridge_not_ready\b/i,/^\s*gateway not connected\b/i],TIMEOUT_PATTERNS=[/^\s*prompt timed out\b/i,/^\s*im runtime dispatch timed out\b/i,/^\s*gateway\b.*\btimed out\b/i,/^\s*deadline exceeded\b/i];function matchesAny(e,r){return r.some(r=>r.test(e))}function extractLockDiagnostics(e){const r=e.match(/\bpid=(\d+)\b/i)?.[1],t=e.match(/([^\s]+\.jsonl\.lock)\b/)?.[1];return{...r?{lockPid:r}:{},...t?{lockPath:t}:{}}}function asString(e){if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}}export function classifyAgentRuntimeFailure(e){return detectAgentRuntimeFailure(e)??classifyProviderRuntimeFailure(e)}export function detectAgentRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim();return e.requestAborted?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,OPENCLAW_SESSION_LOCK_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",diagnosticMessage:t,source:e.source,runtimeKind:r,...extractLockDiagnostics(t)}:matchesAny(t,ABORT_PATTERNS)?{kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"request_aborted",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,AGENT_LOST_PATTERNS)?{kind:"interrupted",stopReason:"agent_lost",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"agent_lost",diagnosticMessage:t,source:e.source,runtimeKind:r}:matchesAny(t,TIMEOUT_PATTERNS)?{kind:"interrupted",stopReason:"timeout",publicMessage:TIMEOUT_PUBLIC_MESSAGE,errorCode:"gateway_timeout",diagnosticMessage:t,source:e.source,runtimeKind:r}:void 0}function classifyProviderRuntimeFailure(e){const r=e.runtimeKind??"openclaw",t=asString(e.raw).trim(),i=t.toLowerCase(),s=formatFriendlyError(t);return{kind:"error",stopReason:"error",publicMessage:s&&s!==t?s:GENERIC_RUNTIME_ERROR_PUBLIC_MESSAGE,errorCode:i?"provider_error":"unknown_runtime_error",diagnosticMessage:t,source:e.source,runtimeKind:r}}export function isRuntimeFailureInterrupted(e){return"interrupted"===e.kind}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it}from"vitest";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";describe("classifyAgentRuntimeFailure",()=>{it("redacts OpenClaw session lock diagnostics into an interrupted state",()=>{const e=classifyAgentRuntimeFailure({raw:"Agent failed before reply: session file locked (timeout 120000ms): pid=357 /home/node/.openclaw/agents/main/sessions/s1.jsonl.lock.",source:"history_replay",runtimeKind:"openclaw"});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",lockPid:"357",lockPath:"/home/node/.openclaw/agents/main/sessions/s1.jsonl.lock"}),expect(e.publicMessage).not.toContain(".jsonl.lock"),expect(e.publicMessage).not.toContain("pid=357")}),it("maps abort diagnostics to interrupted",()=>{expect(classifyAgentRuntimeFailure({raw:"Request was aborted.",source:"live_gateway"})).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted"})}),it("keeps provider/model failures as error with friendly public text",()=>{const e=classifyAgentRuntimeFailure({raw:"Provider returned 429 rate limit",source:"live_gateway"});expect(e.kind).toBe("error"),expect(e.stopReason).toBe("error"),expect(e.errorCode).toBe("provider_error"),expect(e.publicMessage.length).toBeGreaterThan(0)}),it("never exposes an unknown diagnostic or credential as public text",()=>{const e="unexpected gateway detail token=super-secret /home/node/private",t=classifyAgentRuntimeFailure({raw:e,source:"im_runtime"});expect(t.publicMessage).toBe("The agent could not complete the request. Please retry."),expect(t.publicMessage).not.toContain("super-secret"),expect(t.diagnosticMessage).toBe(e)})});
|
|
1
|
+
import{describe,expect,it}from"vitest";import{classifyAgentRuntimeFailure,INTERRUPTED_PUBLIC_MESSAGE}from"./agent-runtime-failure-classifier.js";describe("classifyAgentRuntimeFailure",()=>{it("redacts OpenClaw session lock diagnostics into an interrupted state",()=>{const e=classifyAgentRuntimeFailure({raw:"Agent failed before reply: session file locked (timeout 120000ms): pid=357 /home/node/.openclaw/agents/main/sessions/s1.jsonl.lock.",source:"history_replay",runtimeKind:"openclaw"});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",publicMessage:INTERRUPTED_PUBLIC_MESSAGE,errorCode:"session_file_locked",lockPid:"357",lockPath:"/home/node/.openclaw/agents/main/sessions/s1.jsonl.lock"}),expect(e.publicMessage).not.toContain(".jsonl.lock"),expect(e.publicMessage).not.toContain("pid=357")}),it("maps abort diagnostics to interrupted",()=>{expect(classifyAgentRuntimeFailure({raw:"Request was aborted.",source:"live_gateway"})).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted"})}),it("uses authoritative cancellation state without discarding the raw diagnostic",()=>{const e=classifyAgentRuntimeFailure({raw:new Error("opaque runtime rejection"),source:"im_runtime",requestAborted:!0});expect(e).toMatchObject({kind:"interrupted",stopReason:"interrupted",errorCode:"request_aborted",diagnosticMessage:"opaque runtime rejection"})}),it("keeps provider/model failures as error with friendly public text",()=>{const e=classifyAgentRuntimeFailure({raw:"Provider returned 429 rate limit",source:"live_gateway"});expect(e.kind).toBe("error"),expect(e.stopReason).toBe("error"),expect(e.errorCode).toBe("provider_error"),expect(e.publicMessage.length).toBeGreaterThan(0)}),it("never exposes an unknown diagnostic or credential as public text",()=>{const e="unexpected gateway detail token=super-secret /home/node/private",t=classifyAgentRuntimeFailure({raw:e,source:"im_runtime"});expect(t.publicMessage).toBe("The agent could not complete the request. Please retry."),expect(t.publicMessage).not.toContain("super-secret"),expect(t.diagnosticMessage).toBe(e)})});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beeos-claw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.60",
|
|
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>",
|