beeos-claw 0.5.54 → 0.5.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-gateway/assistant-mq-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-create.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-lifecycle.test.js +1 -1
- package/dist/acp-gateway/bridge-core-apply-template.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-channel-reuse.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-internal-delivery.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-meta-hydration.test.js +1 -1
- package/dist/acp-gateway/bridge-core-cron-timeout.test.js +1 -1
- package/dist/acp-gateway/bridge-core-history-page.test.js +1 -1
- package/dist/acp-gateway/bridge-core-persisted-tool-result.test.js +1 -1
- package/dist/acp-gateway/bridge-core-ping.test.js +1 -1
- package/dist/acp-gateway/bridge-core-session-mutation-lifecycle.test.js +1 -0
- package/dist/acp-gateway/bridge-core-session-new.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-backfill.test.js +1 -1
- package/dist/acp-gateway/gateway-events-cron-name.test.js +1 -1
- package/dist/acp-gateway/gateway-events-file-artifact.test.js +1 -1
- package/dist/acp-gateway/gateway-events-heartbeat.test.js +1 -1
- package/dist/acp-gateway/gateway-events.js +1 -1
- package/dist/acp-gateway/session-load-inflight-user.test.js +1 -1
- package/dist/acp-gateway/session-load.test.js +1 -1
- package/dist/acp-server-agent-sync.test.js +1 -1
- package/dist/acp-server-bridge-shim.test.js +1 -1
- package/dist/acp-server.js +1 -1
- package/dist/canvas-reregister-session-race.test.js +1 -1
- package/dist/im-inbound-handler.js +1 -1
- package/dist/im-inbound-handler.test.js +1 -1
- package/dist/im-runtime-dispatch-session-guard.test.js +1 -1
- package/dist/im-runtime-dispatch.js +1 -1
- package/dist/im-runtime-dispatch.test.js +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/lifecycle-promise-observer.js +1 -0
- package/dist/runtime/lifecycle-promise-observer.test.js +1 -0
- package/dist/runtime/prompt-run-store.js +1 -1
- package/dist/runtime/prompt-run-store.test.js +1 -1
- package/dist/runtime/session-mutation-coordinator.js +1 -0
- package/dist/runtime/session-mutation-coordinator.test.js +1 -0
- package/dist/test-fixtures/im-runtime.fixture.js +1 -1
- package/dist/test-fixtures/session-mutation.fixture.js +1 -0
- package/package.json +1 -1
- package/dist/runtime/openclaw-process-ownership-hook-registrar.js +0 -1
- package/dist/runtime/openclaw-process-ownership-hook-registrar.test.js +0 -1
- package/dist/runtime/openclaw-process-ownership-hook.js +0 -1
- package/dist/runtime/openclaw-process-ownership-hook.test.js +0 -1
- package/dist/runtime/process-session-terminator.js +0 -1
- package/dist/runtime/process-session-terminator.test.js +0 -1
- package/dist/runtime/run-process-lifecycle-coordinator.js +0 -1
- package/dist/runtime/run-process-lifecycle-coordinator.test.js +0 -1
- package/dist/runtime/run-process-ownership-registry.js +0 -1
- package/dist/runtime/run-process-ownership-registry.test.js +0 -1
- package/dist/runtime/session-write-gate-release-coordinator.js +0 -1
- package/dist/runtime/session-write-gate-release-coordinator.test.js +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";import{MessageQueue}from"../utils/mq.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"a"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeSession(e,s){return{sessionId:e,gatewaySessionKey:s,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";import{MessageQueue}from"../utils/mq.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"a"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeSession(e,s){return{sessionId:e,gatewaySessionKey:s,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set}}function buildCore(){const e=makeLogger(),s=vi.fn(),t=new AcpGatewaySessionState(e),n=vi.fn(),a=new AcpTransport(n,s);return{core:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:a,config:makeConfig(),logger:e,writeObs:s,getGateway:()=>null,instanceId:"test-instance"}),state:t,transport:a,logger:e,writeObs:s,sendBridgeMessage:n}}describe("assistantMq lifecycle — endRun closes mq immediately",()=>{it("cleanupPrompt closes and clears assistantMq synchronously (no late-close timer)",()=>{const{core:e,state:s}=buildCore(),t=makeSession("s1","agent:main:s1");t.assistantMq=new MessageQueue,t.currentRunId="run-1",s.sessions.set(t.sessionId,t),s.sessionsByGatewayKey.set(t.gatewaySessionKey,t);const n=t.assistantMq;expect(n.closed).toBe(!1),e.cleanupPrompt(t),expect(n.closed).toBe(!0),expect(t.assistantMq).toBeUndefined(),expect(t.currentRunId).toBeUndefined(),expect(t.pendingPromptId).toBeUndefined()}),it("handleSessionMessage does NOT touch the mq (mq lifecycle is decoupled from persistence signal)",()=>{const{core:e,state:s}=buildCore(),t=makeSession("s1","agent:main:s1");t.assistantMq=new MessageQueue,t.lastTurnIdByRole={assistant:"turn-1"},s.sessions.set(t.sessionId,t),s.sessionsByGatewayKey.set(t.gatewaySessionKey,t);const n=t.assistantMq;e.handleSessionMessage({sessionKey:t.gatewaySessionKey,messageId:"msg-1",message:{role:"assistant",timestamp:Date.now()}}),expect(n.closed).toBe(!1),expect(t.assistantMq).toBe(n)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore,normalizeAgentId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,request:vi.fn(async(e,t)=>"agents.create"===e?{ok:!0,agentId:t.name,name:t.name,workspace:t.workspace}:"agents.update"===e?{ok:!0,agentId:t.agentId}:"agents.files.set"===e?{ok:!0,agentId:t.agentId,workspace:"/ws",file:{name:t.name}}:{})},s=vi.fn(async()=>{const e=n.request.mock.calls.find(e=>"agents.create"===e[0]),t=e?e[1].name:void 0;t&&
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore,normalizeAgentId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,request:vi.fn(async(e,t)=>"agents.create"===e?{ok:!0,agentId:t.name,name:t.name,workspace:t.workspace}:"agents.update"===e?{ok:!0,agentId:t.agentId}:"agents.files.set"===e?{ok:!0,agentId:t.agentId,workspace:"/ws",file:{name:t.name}}:{})},s=vi.fn(async()=>{const e=n.request.mock.calls.find(e=>"agents.create"===e[0]),t=e?e[1].name:void 0;t&&o.setAgentMapping(new Map([[t,{id:`agent_platform_${t}`}]]))}),o=new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:a,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:s});return{bridge:o,state:t,transport:a,gateway:n,logger:e,triggerAgentSync:s}}async function callAgentCreate(e,t){const a={jsonrpc:"2.0",id:1,method:"agent/create",params:t};await e.bridge.handleAgentCreate(a),await Promise.resolve();const n=e.transport.sendResult.mock.calls[0]?.[1];if(!n)throw new Error("agent/create did not call sendResult");return n}describe("handleAgentCreate",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("rejects an empty name with InvalidParams",async()=>{await e.bridge.handleAgentCreate({jsonrpc:"2.0",id:1,method:"agent/create",params:{name:" "}}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.transport.sendResult).not.toHaveBeenCalled()}),it("synthesizes a random id and re-applies the display name for an ASCII name",async()=>{const t=await callAgentCreate(e,{name:"coder"});expect(t.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(t.agentId).not.toBe("coder");const a=e.gateway.request.mock.calls.find(e=>"agents.create"===e[0]);expect((a?.[1]).name).toBe(t.agentId),expect((a?.[1]).workspace).toContain(`workspace-${t.agentId}`);const n=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(n?.[1]).toMatchObject({agentId:t.agentId,name:"coder"}),expect(t.platformAgentId).toBe(`agent_platform_${t.agentId}`)}),it("synthesizes a random id and re-applies the display name for a pure-Chinese name",async()=>{expect(normalizeAgentId("助手")).toBe("main");const t=await callAgentCreate(e,{name:"助手"});expect(t.agentId).not.toBe("main"),expect(t.agentId).toMatch(/^agent-[0-9a-f]{12}$/);const a=e.gateway.request.mock.calls.find(e=>"agents.create"===e[0]);expect((a?.[1]).name).toBe(t.agentId);const n=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(n?.[1]).toMatchObject({agentId:t.agentId,name:"助手"})}),it("seeds bootstrap files but refuses IDENTITY.md",async()=>{await callAgentCreate(e,{name:"writer",files:{"SOUL.md":"you are a writer","IDENTITY.md":"should be ignored"}});const t=e.gateway.request.mock.calls.filter(e=>"agents.files.set"===e[0]).map(e=>e[1].name);expect(t).toContain("SOUL.md"),expect(t).not.toContain("IDENTITY.md")}),it("passes model and identity emoji/avatar through to agents.create",async()=>{await callAgentCreate(e,{name:"coder",model:"deepseek-chat",identity:{emoji:"🤖",avatar:"https://x/y.png"}});const t=e.gateway.request.mock.calls.find(e=>"agents.create"===e[0]);expect(t?.[1]).toMatchObject({model:"deepseek-chat",emoji:"🤖",avatar:"https://x/y.png"})})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,request:vi.fn(async(e,t)=>"agents.update"===e?{ok:!0,agentId:t.agentId}:"agents.delete"===e?{ok:!0,agentId:t.agentId,removedBindings:2}:{})},s=vi.fn(async()=>{});return{bridge:new AcpGatewayBridgeCore({state:t,transport:a,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:s}),state:t,transport:a,gateway:n,logger:e,triggerAgentSync:s}}function callUpdate(e,t){const a={jsonrpc:"2.0",id:1,method:"agent/update",params:t};return e.bridge.handleAgentUpdate(a)}function callDelete(e,t){const a={jsonrpc:"2.0",id:1,method:"agent/delete",params:t};return e.bridge.handleAgentDelete(a)}function callList(e){return e.bridge.handleAgentList({jsonrpc:"2.0",id:1,method:"agent/list",params:{}})}describe("handleAgentUpdate",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("renames via agents.update without changing the agentId, then re-syncs",async()=>{await callUpdate(e,{agentId:"coder",name:"新名字"});const t=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",name:"新名字"}),expect(e.triggerAgentSync).toHaveBeenCalledTimes(1);const a=e.transport.sendResult.mock.calls[0]?.[1];expect(a).toMatchObject({agentId:"coder"}),expect(e.transport.sendAcpError).not.toHaveBeenCalled()}),it("forwards model and identity when present",async()=>{await callUpdate(e,{agentId:"coder",model:"deepseek-chat",identity:{emoji:"🤖",avatar:"https://x/y.png"}});const t=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",model:"deepseek-chat",emoji:"🤖",avatar:"https://x/y.png"})}),it("rejects a missing agentId",async()=>{await callUpdate(e,{name:"新名字"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()}),it("rejects an update with no mutable fields",async()=>{await callUpdate(e,{agentId:"coder"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()})}),describe("handleAgentDelete",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("deletes via agents.delete (deleteFiles defaults true), then re-syncs",async()=>{await callDelete(e,{agentId:"coder"});const t=e.gateway.request.mock.calls.find(e=>"agents.delete"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",deleteFiles:!0}),expect(e.triggerAgentSync).toHaveBeenCalledTimes(1);const a=e.transport.sendResult.mock.calls[0]?.[1];expect(a).toMatchObject({agentId:"coder",removedBindings:2})}),it("honors an explicit deleteFiles=false",async()=>{await callDelete(e,{agentId:"coder",deleteFiles:!1});const t=e.gateway.request.mock.calls.find(e=>"agents.delete"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",deleteFiles:!1})}),it("rejects deleting the reserved main agent BEFORE touching the gateway",async()=>{await callDelete(e,{agentId:"main"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled(),expect(e.triggerAgentSync).not.toHaveBeenCalled()}),it("rejects a name that normalizes to main (e.g. a pure-non-ASCII id)",async()=>{await callDelete(e,{agentId:"助手"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()}),it("rejects a missing agentId",async()=>{await callDelete(e,{}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()})}),describe("handleAgentList",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("maps agents.list rows (incl. main) with identity.name || name || id",async()=>{e.gateway.request.mockImplementationOnce(async()=>({defaultId:"main",mainKey:"main",scope:"gateway",agents:[{id:"main",name:"Main"},{id:"agent-abc123",name:"coder-slug",identity:{name:"编程助手",emoji:"🤖",avatarUrl:"https://x/a.png"}},{id:"agent-def456",identity:{avatar:"https://x/b.png"}}]})),await callList(e),expect(e.gateway.request).toHaveBeenCalledWith("agents.list",{});const t=e.transport.sendResult.mock.calls[0]?.[1];expect(t).toEqual({defaultId:"main",agents:[{agentId:"main",name:"Main"},{agentId:"agent-abc123",name:"编程助手",emoji:"🤖",avatarUrl:"https://x/a.png"},{agentId:"agent-def456",name:"agent-def456",avatarUrl:"https://x/b.png"}]}),expect(e.transport.sendAcpError).not.toHaveBeenCalled()}),it("returns an empty list when the gateway reports no agents",async()=>{e.gateway.request.mockImplementationOnce(async()=>({agents:[]})),await callList(e);const t=e.transport.sendResult.mock.calls[0]?.[1];expect(t).toEqual({agents:[]})}),it("propagates a gateway error as InternalError",async()=>{e.gateway.request.mockImplementationOnce(async()=>{throw new Error("boom")}),await callList(e),expect(e.transport.sendResult).not.toHaveBeenCalled(),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1);const[,,t]=e.transport.sendAcpError.mock.calls[0]??[];expect(String(t?.message)).toContain("boom")}),it("rejects when the gateway is not connected",async()=>{e.gateway.isConnected=!1,await callList(e),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()})});
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,request:vi.fn(async(e,t)=>"agents.update"===e?{ok:!0,agentId:t.agentId}:"agents.delete"===e?{ok:!0,agentId:t.agentId,removedBindings:2}:{})},s=vi.fn(async()=>{});return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:a,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:s}),state:t,transport:a,gateway:n,logger:e,triggerAgentSync:s}}function callUpdate(e,t){const a={jsonrpc:"2.0",id:1,method:"agent/update",params:t};return e.bridge.handleAgentUpdate(a)}function callDelete(e,t){const a={jsonrpc:"2.0",id:1,method:"agent/delete",params:t};return e.bridge.handleAgentDelete(a)}function callList(e){return e.bridge.handleAgentList({jsonrpc:"2.0",id:1,method:"agent/list",params:{}})}describe("handleAgentUpdate",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("renames via agents.update without changing the agentId, then re-syncs",async()=>{await callUpdate(e,{agentId:"coder",name:"新名字"});const t=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",name:"新名字"}),expect(e.triggerAgentSync).toHaveBeenCalledTimes(1);const a=e.transport.sendResult.mock.calls[0]?.[1];expect(a).toMatchObject({agentId:"coder"}),expect(e.transport.sendAcpError).not.toHaveBeenCalled()}),it("forwards model and identity when present",async()=>{await callUpdate(e,{agentId:"coder",model:"deepseek-chat",identity:{emoji:"🤖",avatar:"https://x/y.png"}});const t=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",model:"deepseek-chat",emoji:"🤖",avatar:"https://x/y.png"})}),it("rejects a missing agentId",async()=>{await callUpdate(e,{name:"新名字"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()}),it("rejects an update with no mutable fields",async()=>{await callUpdate(e,{agentId:"coder"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()})}),describe("handleAgentDelete",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("deletes via agents.delete (deleteFiles defaults true), then re-syncs",async()=>{await callDelete(e,{agentId:"coder"});const t=e.gateway.request.mock.calls.find(e=>"agents.delete"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",deleteFiles:!0}),expect(e.triggerAgentSync).toHaveBeenCalledTimes(1);const a=e.transport.sendResult.mock.calls[0]?.[1];expect(a).toMatchObject({agentId:"coder",removedBindings:2})}),it("honors an explicit deleteFiles=false",async()=>{await callDelete(e,{agentId:"coder",deleteFiles:!1});const t=e.gateway.request.mock.calls.find(e=>"agents.delete"===e[0]);expect(t?.[1]).toMatchObject({agentId:"coder",deleteFiles:!1})}),it("rejects deleting the reserved main agent BEFORE touching the gateway",async()=>{await callDelete(e,{agentId:"main"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled(),expect(e.triggerAgentSync).not.toHaveBeenCalled()}),it("rejects a name that normalizes to main (e.g. a pure-non-ASCII id)",async()=>{await callDelete(e,{agentId:"助手"}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()}),it("rejects a missing agentId",async()=>{await callDelete(e,{}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()})}),describe("handleAgentList",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("maps agents.list rows (incl. main) with identity.name || name || id",async()=>{e.gateway.request.mockImplementationOnce(async()=>({defaultId:"main",mainKey:"main",scope:"gateway",agents:[{id:"main",name:"Main"},{id:"agent-abc123",name:"coder-slug",identity:{name:"编程助手",emoji:"🤖",avatarUrl:"https://x/a.png"}},{id:"agent-def456",identity:{avatar:"https://x/b.png"}}]})),await callList(e),expect(e.gateway.request).toHaveBeenCalledWith("agents.list",{});const t=e.transport.sendResult.mock.calls[0]?.[1];expect(t).toEqual({defaultId:"main",agents:[{agentId:"main",name:"Main"},{agentId:"agent-abc123",name:"编程助手",emoji:"🤖",avatarUrl:"https://x/a.png"},{agentId:"agent-def456",name:"agent-def456",avatarUrl:"https://x/b.png"}]}),expect(e.transport.sendAcpError).not.toHaveBeenCalled()}),it("returns an empty list when the gateway reports no agents",async()=>{e.gateway.request.mockImplementationOnce(async()=>({agents:[]})),await callList(e);const t=e.transport.sendResult.mock.calls[0]?.[1];expect(t).toEqual({agents:[]})}),it("propagates a gateway error as InternalError",async()=>{e.gateway.request.mockImplementationOnce(async()=>{throw new Error("boom")}),await callList(e),expect(e.transport.sendResult).not.toHaveBeenCalled(),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1);const[,,t]=e.transport.sendAcpError.mock.calls[0]??[];expect(String(t?.message)).toContain("boom")}),it("rejects when the gateway is not connected",async()=>{e.gateway.isConnected=!1,await callList(e),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.gateway.request).not.toHaveBeenCalled()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import AdmZip from"adm-zip";import{existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,beforeEach,describe,expect,it,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function buildPackage(e){const t=new AdmZip,a={schema_version:1,slug:e.slug,display_name:e.displayName,version:"1.0.0",vibe:e.vibe??"",skills:(e.skills??[]).map(e=>({slug:e,version:"1.0.0"}))};t.addFile("manifest.json",Buffer.from(JSON.stringify(a))),t.addFile("workspace/SOUL.md",Buffer.from(`# SOUL of ${e.slug}\n`)),t.addFile("workspace/AGENTS.md",Buffer.from(`# AGENTS of ${e.slug}\n`));for(const a of e.skills??[])t.addFile(`workspace/skills/${a}/SKILL.md`,Buffer.from(`# skill ${a} for ${e.slug}\n`));return t.toBuffer()}function stubFetch(e){vi.stubGlobal("fetch",vi.fn(async()=>{if(Buffer.isBuffer(e)){const t=e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength);return{ok:!0,status:200,arrayBuffer:async()=>t}}return{ok:!1,status:e.status,arrayBuffer:async()=>new ArrayBuffer(0)}}))}function createHarness(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a=[],s=new Map,n=e=>{const t=s.get(e);if(!t)return;const a=["# IDENTITY.md","",`- Name: ${t.name}`];t.emoji&&a.push(`- Emoji: ${t.emoji}`),writeFileSync(join(t.workspace,"IDENTITY.md"),a.join("\n")+"\n")},r={isConnected:!0,request:vi.fn(async(e,t)=>{if(a.push({method:e,params:t}),"agents.create"===e){const e=t.workspace;mkdirSync(e,{recursive:!0});const a=String(t.name);return s.set(a,{workspace:e,name:a,emoji:t.emoji?String(t.emoji):void 0}),n(a),{ok:!0,agentId:a,workspace:e}}if("agents.update"===e){const e=String(t.agentId),a=s.get(e);return a&&(t.name&&(a.name=String(t.name)),t.emoji&&(a.emoji=String(t.emoji)),n(e)),{ok:!0}}return"agents.list"===e?{agents:[]}:{ok:!0}})},i={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o=vi.fn(async()=>{});return{bridge:new AcpGatewayBridgeCore({state:t,transport:i,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>r,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:o}),gateway:r,transport:i,calls:a}}function applyMsg(e,t){return{jsonrpc:"2.0",id:e,method:"agent/applyTemplate",params:t}}let home;beforeEach(()=>{home=mkdtempSync(join(tmpdir(),"applytpl-home-")),process.env.OPENCLAW_HOME=home}),afterEach(()=>{vi.unstubAllGlobals(),delete process.env.OPENCLAW_HOME,rmSync(home,{recursive:!0,force:!0})}),describe("agent/applyTemplate — live download → unzip → merge into isolated workspace",()=>{it("materializes bootstrap + skills into a unique workspace-<agentId> folder",async()=>{stubFetch(buildPackage({slug:"litigation-advisor",displayName:"诉讼纠纷顾问",vibe:"法条不瞎背",skills:["legal-research","case-analysis"]}));const{bridge:e,transport:t}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://example.com/p.zip",identity:{emoji:"⚖️"}})),expect(t.sendAcpError).not.toHaveBeenCalled();const a=t.sendResult.mock.calls[0][1];expect(a.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(a.workspace).toBe(join(home,`workspace-${a.agentId}`)),expect(readFileSync(join(a.workspace,"SOUL.md"),"utf8")).toContain("SOUL of litigation-advisor"),expect(existsSync(join(a.workspace,"AGENTS.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/legal-research/SKILL.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/case-analysis/SKILL.md"))).toBe(!0);const s=readFileSync(join(a.workspace,"IDENTITY.md"),"utf8");expect(s).toContain("诉讼纠纷顾问"),expect(s).toContain("⚖️")}),it("keeps multiple agents in separate folders with no cross-contamination",async()=>{const{bridge:e,transport:t}=createHarness();stubFetch(buildPackage({slug:"litigation-advisor",displayName:"诉讼纠纷顾问",skills:["legal-research"]})),await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/a.zip"}));const a=t.sendResult.mock.calls[0][1];stubFetch(buildPackage({slug:"tax-advisor",displayName:"财税顾问",skills:["tax-filing"]})),await e.handleRequest(applyMsg(2,{name:"财税顾问",packageUrl:"https://x/b.zip"}));const s=t.sendResult.mock.calls[1][1];expect(a.workspace).not.toBe(s.workspace),expect(existsSync(join(a.workspace,"skills/legal-research/SKILL.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/tax-filing/SKILL.md"))).toBe(!1),expect(existsSync(join(s.workspace,"skills/tax-filing/SKILL.md"))).toBe(!0),expect(existsSync(join(s.workspace,"skills/legal-research/SKILL.md"))).toBe(!1)}),it("allows duplicate names: same name yields distinct random ids and folders",async()=>{const{bridge:e,transport:t}=createHarness();stubFetch(buildPackage({slug:"researcher",displayName:"Researcher",skills:["search"]})),await e.handleRequest(applyMsg(1,{name:"researcher",packageUrl:"https://x/a.zip"}));const a=t.sendResult.mock.calls[0][1];stubFetch(buildPackage({slug:"researcher",displayName:"Researcher",skills:["search"]})),await e.handleRequest(applyMsg(2,{name:"researcher",packageUrl:"https://x/b.zip"}));const s=t.sendResult.mock.calls[1][1];expect(a.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(s.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(a.agentId).not.toBe(s.agentId),expect(a.workspace).not.toBe(s.workspace)}),it("rejects a malformed package without creating an agent (fail-fast)",async()=>{stubFetch(Buffer.from("this is not a zip"));const{bridge:e,transport:t,calls:a}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/bad.zip"})),expect(t.sendResult).not.toHaveBeenCalled(),expect(t.sendAcpError).toHaveBeenCalled(),expect(a.find(e=>"agents.create"===e.method)).toBeUndefined()}),it("surfaces a download failure as an error",async()=>{stubFetch({ok:!1,status:403});const{bridge:e,transport:t}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/denied.zip"})),expect(t.sendResult).not.toHaveBeenCalled(),expect(t.sendAcpError).toHaveBeenCalled()})});
|
|
1
|
+
import AdmZip from"adm-zip";import{existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,beforeEach,describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function buildPackage(e){const t=new AdmZip,a={schema_version:1,slug:e.slug,display_name:e.displayName,version:"1.0.0",vibe:e.vibe??"",skills:(e.skills??[]).map(e=>({slug:e,version:"1.0.0"}))};t.addFile("manifest.json",Buffer.from(JSON.stringify(a))),t.addFile("workspace/SOUL.md",Buffer.from(`# SOUL of ${e.slug}\n`)),t.addFile("workspace/AGENTS.md",Buffer.from(`# AGENTS of ${e.slug}\n`));for(const a of e.skills??[])t.addFile(`workspace/skills/${a}/SKILL.md`,Buffer.from(`# skill ${a} for ${e.slug}\n`));return t.toBuffer()}function stubFetch(e){vi.stubGlobal("fetch",vi.fn(async()=>{if(Buffer.isBuffer(e)){const t=e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength);return{ok:!0,status:200,arrayBuffer:async()=>t}}return{ok:!1,status:e.status,arrayBuffer:async()=>new ArrayBuffer(0)}}))}function createHarness(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a=[],s=new Map,n=e=>{const t=s.get(e);if(!t)return;const a=["# IDENTITY.md","",`- Name: ${t.name}`];t.emoji&&a.push(`- Emoji: ${t.emoji}`),writeFileSync(join(t.workspace,"IDENTITY.md"),a.join("\n")+"\n")},r={isConnected:!0,request:vi.fn(async(e,t)=>{if(a.push({method:e,params:t}),"agents.create"===e){const e=t.workspace;mkdirSync(e,{recursive:!0});const a=String(t.name);return s.set(a,{workspace:e,name:a,emoji:t.emoji?String(t.emoji):void 0}),n(a),{ok:!0,agentId:a,workspace:e}}if("agents.update"===e){const e=String(t.agentId),a=s.get(e);return a&&(t.name&&(a.name=String(t.name)),t.emoji&&(a.emoji=String(t.emoji)),n(e)),{ok:!0}}return"agents.list"===e?{agents:[]}:{ok:!0}})},i={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o=vi.fn(async()=>{});return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:i,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>r,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:o}),gateway:r,transport:i,calls:a}}function applyMsg(e,t){return{jsonrpc:"2.0",id:e,method:"agent/applyTemplate",params:t}}let home;beforeEach(()=>{home=mkdtempSync(join(tmpdir(),"applytpl-home-")),process.env.OPENCLAW_HOME=home}),afterEach(()=>{vi.unstubAllGlobals(),delete process.env.OPENCLAW_HOME,rmSync(home,{recursive:!0,force:!0})}),describe("agent/applyTemplate — live download → unzip → merge into isolated workspace",()=>{it("materializes bootstrap + skills into a unique workspace-<agentId> folder",async()=>{stubFetch(buildPackage({slug:"litigation-advisor",displayName:"诉讼纠纷顾问",vibe:"法条不瞎背",skills:["legal-research","case-analysis"]}));const{bridge:e,transport:t}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://example.com/p.zip",identity:{emoji:"⚖️"}})),expect(t.sendAcpError).not.toHaveBeenCalled();const a=t.sendResult.mock.calls[0][1];expect(a.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(a.workspace).toBe(join(home,`workspace-${a.agentId}`)),expect(readFileSync(join(a.workspace,"SOUL.md"),"utf8")).toContain("SOUL of litigation-advisor"),expect(existsSync(join(a.workspace,"AGENTS.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/legal-research/SKILL.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/case-analysis/SKILL.md"))).toBe(!0);const s=readFileSync(join(a.workspace,"IDENTITY.md"),"utf8");expect(s).toContain("诉讼纠纷顾问"),expect(s).toContain("⚖️")}),it("keeps multiple agents in separate folders with no cross-contamination",async()=>{const{bridge:e,transport:t}=createHarness();stubFetch(buildPackage({slug:"litigation-advisor",displayName:"诉讼纠纷顾问",skills:["legal-research"]})),await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/a.zip"}));const a=t.sendResult.mock.calls[0][1];stubFetch(buildPackage({slug:"tax-advisor",displayName:"财税顾问",skills:["tax-filing"]})),await e.handleRequest(applyMsg(2,{name:"财税顾问",packageUrl:"https://x/b.zip"}));const s=t.sendResult.mock.calls[1][1];expect(a.workspace).not.toBe(s.workspace),expect(existsSync(join(a.workspace,"skills/legal-research/SKILL.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/tax-filing/SKILL.md"))).toBe(!1),expect(existsSync(join(s.workspace,"skills/tax-filing/SKILL.md"))).toBe(!0),expect(existsSync(join(s.workspace,"skills/legal-research/SKILL.md"))).toBe(!1)}),it("allows duplicate names: same name yields distinct random ids and folders",async()=>{const{bridge:e,transport:t}=createHarness();stubFetch(buildPackage({slug:"researcher",displayName:"Researcher",skills:["search"]})),await e.handleRequest(applyMsg(1,{name:"researcher",packageUrl:"https://x/a.zip"}));const a=t.sendResult.mock.calls[0][1];stubFetch(buildPackage({slug:"researcher",displayName:"Researcher",skills:["search"]})),await e.handleRequest(applyMsg(2,{name:"researcher",packageUrl:"https://x/b.zip"}));const s=t.sendResult.mock.calls[1][1];expect(a.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(s.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(a.agentId).not.toBe(s.agentId),expect(a.workspace).not.toBe(s.workspace)}),it("rejects a malformed package without creating an agent (fail-fast)",async()=>{stubFetch(Buffer.from("this is not a zip"));const{bridge:e,transport:t,calls:a}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/bad.zip"})),expect(t.sendResult).not.toHaveBeenCalled(),expect(t.sendAcpError).toHaveBeenCalled(),expect(a.find(e=>"agents.create"===e.method)).toBeUndefined()}),it("surfaces a download failure as an error",async()=>{stubFetch({ok:!1,status:403});const{bridge:e,transport:t}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/denied.zip"})),expect(t.sendResult).not.toHaveBeenCalled(),expect(t.sendAcpError).toHaveBeenCalled()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore,encodeBeeosChannelTag,stripBeeosChannelTag,decodeBeeosChannelId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";const TAG_RE_GLOBAL=/<!-- beeos:channel=(.+?) -->/g;function countTags(e){return e?[...e.matchAll(TAG_RE_GLOBAL)].length:0}function makeBridge(e){const t={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new AcpGatewaySessionState(t),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o={isConnected:!0,agentCancel:vi.fn(),request:vi.fn().mockImplementation(async t=>{const n=e?.gatewayResponses?.[t];return void 0!==n?n:{id:"job_test_123"}})},s=vi.fn().mockResolvedValue({ok:!0,json:async()=>({id:e?.allocChannelId??"chan_new"})});return{bridge:new AcpGatewayBridgeCore({state:n,transport:a,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:t,writeObs:vi.fn(),getGateway:()=>o,instanceId:"test-instance",thirdPartyChannels:new Set,agentFetch:s,agentGatewayUrl:"http://agent-gateway.test"}),state:n,transport:a,gateway:o,logger:t,agentFetch:s}}function cronUpdateRequest(e,t,n){return{jsonrpc:"2.0",id:e,method:"cron/update",params:{id:t,patch:n}}}function forwardedPatch(e){const t=e.request.mock.calls.find(e=>"cron.update"===e[0]);return expect(t,"expected a cron.update forward").toBeDefined(),t[1].patch}describe("encodeBeeosChannelTag / stripBeeosChannelTag idempotency",()=>{it("re-encoding a description that already carries a tag yields exactly one tag",()=>{const e=encodeBeeosChannelTag("user text","chan_a"),t=encodeBeeosChannelTag(e,"chan_b");expect(countTags(t)).toBe(1),expect(decodeBeeosChannelId(t)).toBe("chan_b"),expect(t.startsWith("user text")).toBe(!0)}),it("strip removes ALL tags (heals legacy double-tag descriptions)",()=>{const e="text\n\x3c!-- beeos:channel=old --\x3e\n\x3c!-- beeos:channel=new --\x3e";expect(stripBeeosChannelTag(e)).toBe("text"),expect(countTags(encodeBeeosChannelTag(e,"chan_c"))).toBe(1)})}),describe("handleCronRequest — cron.update channel reuse",()=>{it("cache hit: reuses the cached channel, no allocation, description untouched",async()=>{const e=makeBridge();e.bridge.cronChannelCache.set("job_test_123","chan_cached"),await e.bridge.handleRequest(cronUpdateRequest(1,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway);expect(t).not.toHaveProperty("description"),expect(t.delivery).toEqual({mode:"none"}),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_cached")}),it("cache miss + cron.list has a tagged description: reuses that channel, no allocation",async()=>{const e=makeBridge({gatewayResponses:{"cron.list":{jobs:[{id:"job_test_123",description:"user text\n\x3c!-- beeos:channel=chan_existing --\x3e"}]}}});await e.bridge.handleRequest(cronUpdateRequest(2,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway);expect(t).not.toHaveProperty("description"),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_existing")}),it("patch echoes the old tag: reuses it and keeps exactly one tag (idempotent encode)",async()=>{const e=makeBridge();await e.bridge.handleRequest(cronUpdateRequest(3,"job_test_123",{name:"renamed",description:"new text\n\x3c!-- beeos:channel=chan_old --\x3e",_beeos_internal:!0})),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway).description;expect(countTags(t)).toBe(1),expect(decodeBeeosChannelId(t)).toBe("chan_old"),expect(t.startsWith("new text")).toBe(!0),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_old")}),it("all sources miss: allocates once and persists the tag on the job's stored description",async()=>{const e=makeBridge({allocChannelId:"chan_new",gatewayResponses:{"cron.list":{jobs:[{id:"job_test_123",description:"keep me"}]}}});await e.bridge.handleRequest(cronUpdateRequest(4,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.agentFetch).toHaveBeenCalledTimes(1);const t=forwardedPatch(e.gateway);expect(t.description).toBe("keep me\n\x3c!-- beeos:channel=chan_new --\x3e"),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_new")}),it("cron.add still always allocates a fresh channel (unchanged behavior)",async()=>{const e=makeBridge({allocChannelId:"chan_add"});await e.bridge.handleRequest({jsonrpc:"2.0",id:5,method:"cron/add",params:{name:"new-job",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},_beeos_internal:!0}}),expect(e.agentFetch).toHaveBeenCalledTimes(1);const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0])[1];expect(decodeBeeosChannelId(t.description)).toBe("chan_add")})}),describe("handleCronRequest — cron.remove cache cleanup",()=>{it("drops the jobId → channelId mapping so late finished events cannot publish",async()=>{const e=makeBridge();e.bridge.cronChannelCache.set("job_test_123","chan_gone"),await e.bridge.handleRequest({jsonrpc:"2.0",id:6,method:"cron/remove",params:{id:"job_test_123"}}),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.bridge.cronChannelCache.has("job_test_123")).toBe(!1)})});
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore,encodeBeeosChannelTag,stripBeeosChannelTag,decodeBeeosChannelId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";const TAG_RE_GLOBAL=/<!-- beeos:channel=(.+?) -->/g;function countTags(e){return e?[...e.matchAll(TAG_RE_GLOBAL)].length:0}function makeBridge(e){const t={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new AcpGatewaySessionState(t),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o={isConnected:!0,agentCancel:vi.fn(),request:vi.fn().mockImplementation(async t=>{const n=e?.gatewayResponses?.[t];return void 0!==n?n:{id:"job_test_123"}})},s=vi.fn().mockResolvedValue({ok:!0,json:async()=>({id:e?.allocChannelId??"chan_new"})});return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:n,transport:a,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:t,writeObs:vi.fn(),getGateway:()=>o,instanceId:"test-instance",thirdPartyChannels:new Set,agentFetch:s,agentGatewayUrl:"http://agent-gateway.test"}),state:n,transport:a,gateway:o,logger:t,agentFetch:s}}function cronUpdateRequest(e,t,n){return{jsonrpc:"2.0",id:e,method:"cron/update",params:{id:t,patch:n}}}function forwardedPatch(e){const t=e.request.mock.calls.find(e=>"cron.update"===e[0]);return expect(t,"expected a cron.update forward").toBeDefined(),t[1].patch}describe("encodeBeeosChannelTag / stripBeeosChannelTag idempotency",()=>{it("re-encoding a description that already carries a tag yields exactly one tag",()=>{const e=encodeBeeosChannelTag("user text","chan_a"),t=encodeBeeosChannelTag(e,"chan_b");expect(countTags(t)).toBe(1),expect(decodeBeeosChannelId(t)).toBe("chan_b"),expect(t.startsWith("user text")).toBe(!0)}),it("strip removes ALL tags (heals legacy double-tag descriptions)",()=>{const e="text\n\x3c!-- beeos:channel=old --\x3e\n\x3c!-- beeos:channel=new --\x3e";expect(stripBeeosChannelTag(e)).toBe("text"),expect(countTags(encodeBeeosChannelTag(e,"chan_c"))).toBe(1)})}),describe("handleCronRequest — cron.update channel reuse",()=>{it("cache hit: reuses the cached channel, no allocation, description untouched",async()=>{const e=makeBridge();e.bridge.cronChannelCache.set("job_test_123","chan_cached"),await e.bridge.handleRequest(cronUpdateRequest(1,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway);expect(t).not.toHaveProperty("description"),expect(t.delivery).toEqual({mode:"none"}),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_cached")}),it("cache miss + cron.list has a tagged description: reuses that channel, no allocation",async()=>{const e=makeBridge({gatewayResponses:{"cron.list":{jobs:[{id:"job_test_123",description:"user text\n\x3c!-- beeos:channel=chan_existing --\x3e"}]}}});await e.bridge.handleRequest(cronUpdateRequest(2,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway);expect(t).not.toHaveProperty("description"),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_existing")}),it("patch echoes the old tag: reuses it and keeps exactly one tag (idempotent encode)",async()=>{const e=makeBridge();await e.bridge.handleRequest(cronUpdateRequest(3,"job_test_123",{name:"renamed",description:"new text\n\x3c!-- beeos:channel=chan_old --\x3e",_beeos_internal:!0})),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway).description;expect(countTags(t)).toBe(1),expect(decodeBeeosChannelId(t)).toBe("chan_old"),expect(t.startsWith("new text")).toBe(!0),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_old")}),it("all sources miss: allocates once and persists the tag on the job's stored description",async()=>{const e=makeBridge({allocChannelId:"chan_new",gatewayResponses:{"cron.list":{jobs:[{id:"job_test_123",description:"keep me"}]}}});await e.bridge.handleRequest(cronUpdateRequest(4,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.agentFetch).toHaveBeenCalledTimes(1);const t=forwardedPatch(e.gateway);expect(t.description).toBe("keep me\n\x3c!-- beeos:channel=chan_new --\x3e"),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_new")}),it("cron.add still always allocates a fresh channel (unchanged behavior)",async()=>{const e=makeBridge({allocChannelId:"chan_add"});await e.bridge.handleRequest({jsonrpc:"2.0",id:5,method:"cron/add",params:{name:"new-job",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},_beeos_internal:!0}}),expect(e.agentFetch).toHaveBeenCalledTimes(1);const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0])[1];expect(decodeBeeosChannelId(t.description)).toBe("chan_add")})}),describe("handleCronRequest — cron.remove cache cleanup",()=>{it("drops the jobId → channelId mapping so late finished events cannot publish",async()=>{const e=makeBridge();e.bridge.cronChannelCache.set("job_test_123","chan_gone"),await e.bridge.handleRequest({jsonrpc:"2.0",id:6,method:"cron/remove",params:{id:"job_test_123"}}),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.bridge.cronChannelCache.has("job_test_123")).toBe(!1)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),n={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),n={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o={isConnected:!0,request:vi.fn().mockResolvedValue({id:"job_test_123"})};return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:n,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:e,writeObs:vi.fn(),getGateway:()=>o,instanceId:"test-instance",thirdPartyChannels:new Set}),state:t,transport:n,gateway:o,logger:e}}function cronAddRequest(e,t){return{jsonrpc:"2.0",id:e,method:"cron/add",params:t}}describe("handleCronRequest — _beeos_internal delivery contract (Bug-A)",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("forces delivery={mode:'none'} when _beeos_internal=true (no delivery from client)",async()=>{await e.bridge.handleRequest(cronAddRequest(1,{name:"daily-summary",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},_beeos_internal:!0})),expect(e.transport.sendAcpError).not.toHaveBeenCalled();const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0]);expect(t,"expected a cron.add forward").toBeDefined();const[n,o]=t;expect(n).toBe("cron.add"),expect(o).not.toHaveProperty("_beeos_internal"),expect(o.delivery).toEqual({mode:"none"})}),it("normalizes delivery to {mode:'none'} even when client sent a conflicting delivery",async()=>{await e.bridge.handleRequest(cronAddRequest(2,{name:"daily-summary",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},_beeos_internal:!0,delivery:{mode:"announce",channel:"lark:c_123"}}));const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0]),[,n]=t;expect(n.delivery).toEqual({mode:"none"})}),it("leaves delivery untouched when _beeos_internal is not set",async()=>{await e.bridge.handleRequest(cronAddRequest(3,{name:"announce-job",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},delivery:{mode:"announce",channel:"lark:c_123"}}));const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0]),[,n]=t;expect(n.delivery).toEqual({mode:"announce",channel:"lark:c_123"}),expect(n).not.toHaveProperty("_beeos_internal")}),it("also applies to cron.update — BeeOS-private fields ride inside params.patch (Bug-A2)",async()=>{await e.bridge.handleRequest({jsonrpc:"2.0",id:4,method:"cron/update",params:{id:"job_test_123",patch:{name:"renamed",_beeos_internal:!0}}});const t=e.gateway.request.mock.calls.find(e=>"cron.update"===e[0]);expect(t,"expected a cron.update forward").toBeDefined();const[n,o]=t;expect(n).toBe("cron.update"),expect(o.id).toBe("job_test_123");const a=o.patch;expect(a).not.toHaveProperty("_beeos_internal"),expect(a).not.toHaveProperty("_beeos_meta"),expect(a.delivery).toEqual({mode:"none"}),expect(a.name).toBe("renamed"),expect(o).not.toHaveProperty("_beeos_internal"),expect(o).not.toHaveProperty("_beeos_meta")}),it("strips _beeos_meta nested inside cron.update patch (Bug-A2 regression)",async()=>{await e.bridge.handleRequest({jsonrpc:"2.0",id:6,method:"cron/update",params:{id:"job_test_123",patch:{name:"edited",_beeos_meta:{maxDurationMs:3e5}}}});const t=e.gateway.request.mock.calls.find(e=>"cron.update"===e[0]),[,n]=t,o=n.patch;expect(o).not.toHaveProperty("_beeos_meta"),expect(o.name).toBe("edited")}),it("strips _beeos_internal=false without synthesizing delivery",async()=>{await e.bridge.handleRequest(cronAddRequest(5,{name:"neutral-job",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},delivery:{mode:"webhook",to:"https://example.com/hook"},_beeos_internal:!1}));const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0]),[,n]=t;expect(n).not.toHaveProperty("_beeos_internal"),expect(n.delivery).toEqual({mode:"webhook",to:"https://example.com/hook"})})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),s={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a={isConnected:!0,request:vi.fn()};return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:s,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:e,writeObs:vi.fn(),getGateway:()=>a,instanceId:"test-instance",thirdPartyChannels:new Set}),state:t,transport:s,gateway:a,logger:e}}async function seedOverride(e,t,s){await e.cronOverrides.set(t,s)}describe("handleCronRequest — read-side _beeos_meta hydration",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("cron.list: attaches _beeos_meta.maxDurationMs to jobs with stored overrides",async()=>{await seedOverride(e.bridge,"job_with_override",18e5);e.gateway.request.mockResolvedValueOnce({jobs:[{id:"job_with_override",name:"a",description:null},{id:"job_no_override",name:"b",description:null}]}),await e.bridge.handleRequest({jsonrpc:"2.0",id:1,method:"cron/list",params:{}}),expect(e.transport.sendAcpError).not.toHaveBeenCalled();const[,t]=e.transport.sendResult.mock.calls[0],s=t.jobs;expect(s).toHaveLength(2),expect(s[0]).toMatchObject({id:"job_with_override",_beeos_meta:{maxDurationMs:18e5}}),expect(s[1]).not.toHaveProperty("_beeos_meta")}),it("cron.list: does NOT mutate the OpenClaw response in place",async()=>{await seedOverride(e.bridge,"job_test_xyz",36e5);const t={jobs:[{id:"job_test_xyz",name:"a"}]};e.gateway.request.mockResolvedValueOnce(t),await e.bridge.handleRequest({jsonrpc:"2.0",id:2,method:"cron/list",params:{}}),expect(t.jobs[0]).not.toHaveProperty("_beeos_meta")}),it("add → list round-trip: a freshly saved maxDurationMs surfaces on next list",async()=>{e.gateway.request.mockResolvedValueOnce({id:"job_rt_1"}),await e.bridge.handleRequest({jsonrpc:"2.0",id:1,method:"cron/add",params:{name:"round-trip",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},_beeos_meta:{maxDurationMs:27e5}}});const t=e.bridge.cronOverrides;await vi.waitFor(()=>{expect(t.get("job_rt_1")).toBe(27e5)}),e.gateway.request.mockResolvedValueOnce({jobs:[{id:"job_rt_1",name:"round-trip"}]}),await e.bridge.handleRequest({jsonrpc:"2.0",id:2,method:"cron/list",params:{}});const s=e.transport.sendResult.mock.calls.findIndex(e=>{const t=e[1];return"object"==typeof t&&null!==t&&Array.isArray(t.jobs)});expect(s).toBeGreaterThanOrEqual(0);const[,a]=e.transport.sendResult.mock.calls[s],o=a.jobs;expect(o[0]).toMatchObject({id:"job_rt_1",_beeos_meta:{maxDurationMs:27e5}})}),it("cron.status: enriches keyed `jobs` map entries that carry an id",async()=>{await seedOverride(e.bridge,"job_status_1",9e5),e.gateway.request.mockResolvedValueOnce({jobs:{job_status_1:{id:"job_status_1",state:"running"},job_status_2:{id:"job_status_2",state:"idle"}}}),await e.bridge.handleRequest({jsonrpc:"2.0",id:3,method:"cron/status",params:{}});const[,t]=e.transport.sendResult.mock.calls[0],s=t.jobs;expect(s.job_status_1).toMatchObject({_beeos_meta:{maxDurationMs:9e5}}),expect(s.job_status_2).not.toHaveProperty("_beeos_meta")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},
|
|
1
|
+
import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),o={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,agentCancel:vi.fn(),chatAbort:vi.fn().mockResolvedValue(void 0),request:vi.fn().mockResolvedValue({})};return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:o,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set}),state:t,transport:o,gateway:n,logger:e}}describe("schedulePromptTimeout — adopted background (cron) runs",()=>{beforeEach(()=>{vi.useFakeTimers()}),afterEach(()=>{vi.useRealTimers()}),it("fires for a currentRunId-only session: awaited chat.abort + timeout terminal",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:cron-job-1");t.currentRunId="run-bg-1",t.cronJobId="job-bg-1",t.promptDone=!1,e.bridge.schedulePromptTimeout(t),vi.advanceTimersByTime(300001),await Promise.resolve(),await Promise.resolve(),expect(e.gateway.chatAbort).toHaveBeenCalledWith(t.gatewaySessionKey,"run-bg-1"),expect(t.currentRunId).toBeUndefined(),expect(t.promptDone).toBeUndefined(),expect(e.transport.sendSessionUpdate).toHaveBeenCalledWith(t.sessionId,expect.objectContaining({sessionUpdate:"stream_complete",stopReason:"timeout",errorCode:"gateway_timeout"}),expect.objectContaining({turnId:"run-bg-1"})),expect(e.transport.sendError).not.toHaveBeenCalled()}),it("stays a no-op for a fully idle session (no pendingPromptId, no currentRunId)",()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:idle");e.bridge.schedulePromptTimeout(t),vi.advanceTimersByTime(186e4),expect(e.gateway.chatAbort).not.toHaveBeenCalled(),expect(e.transport.sendSessionUpdate).not.toHaveBeenCalled(),expect(t.promptDone).not.toBe(!0)}),it("does not fire after normal completion cleared the timer (no false kill)",()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:cron-job-2");t.currentRunId="run-bg-2",t.cronJobId="job-bg-2",t.promptDone=!1,e.bridge.schedulePromptTimeout(t),e.bridge.endRun(t,{kind:"complete",stopReason:"end_turn"}),e.gateway.chatAbort.mockClear(),vi.advanceTimersByTime(72e5),expect(e.gateway.chatAbort).not.toHaveBeenCalled()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeGateway(e){return{chatHistoryPage:vi.fn(async(s,t)=>(e.onCall?.(s,t??{}),e.page))}}function buildCore(e){const s=makeLogger(),t=vi.fn(),a=new AcpGatewaySessionState(s),o=vi.fn(),n=new AcpTransport(o,t);return{core:new AcpGatewayBridgeCore({state:a,transport:n,config:makeConfig(),logger:s,writeObs:t,getGateway:()=>e,instanceId:"test-instance"}),state:a,transport:n,logger:s,sendBridgeMessage:o}}function registerSession(e,s){const t={sessionId:s,gatewaySessionKey:`agent:main:${s}`,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set};return e.sessions.set(t.sessionId,t),e.sessionsByGatewayKey.set(t.gatewaySessionKey,t),t}async function callHistoryPage(e,s,t){const a={jsonrpc:"2.0",id:s,method:"_beeos/session/history",params:t};await e.handleRequest(a)}function getResult(e){const s=e.mock.calls.map(e=>e[0]).filter(e=>void 0!==e?.result);return expect(s).toHaveLength(1),s[0].result}function getNotifications(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e?.method)}function entry(e,s,t,a){return{role:e,content:[{type:"text",text:s}],timestamp:17e11+1e3*a,__openclaw:{id:t,seq:a}}}describe("_beeos/session/history — cursor pagination",()=>{it("returns the page updates synchronously and does NOT stream notifications",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","older question","aaaaaaaa",5),entry("assistant","older answer","bbbbbbbb",6)],nextCursor:"5",hasMore:!0}}));registerSession(e.state,"session:page-1"),await callHistoryPage(e.core,1,{sessionId:"session:page-1",cursor:"7"}),expect(getNotifications(e.sendBridgeMessage)).toHaveLength(0);const s=getResult(e.sendBridgeMessage);expect(s.hasMore).toBe(!0),expect(s.nextCursor).toBe("5");const t=s.updates;expect(Array.isArray(t)).toBe(!0);const a=t.map(e=>e.sessionUpdate);expect(a).toContain("user_message_chunk"),expect(a).toContain("agent_message_chunk"),expect(a).toContain("stream_complete")}),it("terminalizes orphan historical tool calls as interrupted",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","take a screenshot","aaaaaaaa",5),{role:"assistant",content:[{type:"tool_use",id:"tc-browser-snapshot",name:"browser",input:{action:"snapshot",targetId:"t1"}}],timestamp:170000001e4,__openclaw:{id:"bbbbbbbb",seq:6}}],hasMore:!1}}));registerSession(e.state,"session:orphan-tool"),await callHistoryPage(e.core,1,{sessionId:"session:orphan-tool"});const s=getResult(e.sendBridgeMessage).updates,t=s.find(e=>"tool_call_update"===e.sessionUpdate&&"tc-browser-snapshot"===e.toolCallId);expect(t).toMatchObject({sessionUpdate:"tool_call_update",toolCallId:"tc-browser-snapshot",status:"failed"});const a=s.find(e=>"stream_complete"===e.sessionUpdate);expect(a).toMatchObject({sessionUpdate:"stream_complete",stopReason:"interrupted",error:"The previous run was interrupted before it completed."});const o=e.logger.info.mock.calls.map(([e])=>String(e)).join("\n");expect(o).toContain("history_orphan_turn_terminalized"),expect(o).not.toContain("take a screenshot")}),it("terminalizes gateway lock failures without replaying raw diagnostics",async()=>{const e=buildCore(makeGateway({page:{messages:[{...entry("assistant","Agent failed before reply: session file locked: pid=357 /home/node/private/session.jsonl.lock token=secret","failure-id",7),metadata:{customType:"openclaw:prompt-error"}}],hasMore:!1}}));registerSession(e.state,"session:runtime-failure"),await callHistoryPage(e.core,1,{sessionId:"session:runtime-failure"});const s=getResult(e.sendBridgeMessage).updates;expect(JSON.stringify(s)).not.toContain("/home/node/private"),expect(JSON.stringify(s)).not.toContain("pid=357"),expect(JSON.stringify(s)).not.toContain("token=secret"),expect(s.find(e=>"stream_complete"===e.sessionUpdate)).toMatchObject({stopReason:"interrupted",error:"The previous run was interrupted before it completed."})}),it("tags updates with seq-namespaced _turnId and propagates _seq",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","hi","aaaaaaaa",42)],hasMore:!1}})),s=registerSession(e.state,"session:seqtag");await callHistoryPage(e.core,1,{sessionId:"session:seqtag"});const t=getResult(e.sendBridgeMessage).updates.find(e=>"user_message_chunk"===e.sessionUpdate);expect(t).toBeDefined(),expect(t?._turnId).toBe(`history:${s.gatewaySessionKey}:seq:42`),expect(t?._seq).toBe(42)}),it("filters out cron-triggered user turns (same as live replay)",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","[cron:abcdef12 daily] run the report","cccccccc",10),entry("user","real question","dddddddd",11)],hasMore:!1}}));registerSession(e.state,"session:cron"),await callHistoryPage(e.core,1,{sessionId:"session:cron"});const s=getResult(e.sendBridgeMessage).updates.filter(e=>"user_message_chunk"===e.sessionUpdate).map(e=>e.content?.text);expect(s).toContain("real question"),expect(s.some(e=>e?.includes("[cron:"))).toBe(!1)}),it("forwards cursor and clamps limit to the hard cap",async()=>{let e={};const s=buildCore(makeGateway({page:{messages:[],hasMore:!1},onCall:(s,t)=>{e={key:s,args:t}}})),t=registerSession(s.state,"session:args");await callHistoryPage(s.core,1,{sessionId:"session:args",cursor:"99",limit:99999}),expect(e.key).toBe(t.gatewaySessionKey),expect(e.args?.cursor).toBe("99"),expect(e.args?.limit).toBe(1e3)}),it("returns an empty page (hasMore:false) when the gateway HTTP call fails",async()=>{const e=buildCore(makeGateway({page:null}));registerSession(e.state,"session:nopage"),await callHistoryPage(e.core,1,{sessionId:"session:nopage"});const s=getResult(e.sendBridgeMessage);expect(s.updates).toEqual([]),expect(s.hasMore).toBe(!1),expect(s.nextCursor).toBeUndefined()}),it("returns an empty page when no gateway is available",async()=>{const e=buildCore(null);registerSession(e.state,"session:nogw"),await callHistoryPage(e.core,1,{sessionId:"session:nogw"});const s=getResult(e.sendBridgeMessage);expect(s.updates).toEqual([]),expect(s.hasMore).toBe(!1)}),it("rejects a request with missing sessionId via sendAcpError",async()=>{const e=buildCore(makeGateway({page:{messages:[],hasMore:!1}}));await callHistoryPage(e.core,1,{});const s=e.sendBridgeMessage.mock.calls.map(e=>e[0]).filter(e=>void 0!==e?.error);expect(s).toHaveLength(1),expect(s[0].error.data?.param).toBe("sessionId")})});
|
|
1
|
+
import{describe,it,expect,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeGateway(e){return{chatHistoryPage:vi.fn(async(s,t)=>(e.onCall?.(s,t??{}),e.page))}}function buildCore(e){const s=makeLogger(),t=vi.fn(),a=new AcpGatewaySessionState(s),o=vi.fn(),n=new AcpTransport(o,t);return{core:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:a,transport:n,config:makeConfig(),logger:s,writeObs:t,getGateway:()=>e,instanceId:"test-instance"}),state:a,transport:n,logger:s,sendBridgeMessage:o}}function registerSession(e,s){const t={sessionId:s,gatewaySessionKey:`agent:main:${s}`,agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set};return e.sessions.set(t.sessionId,t),e.sessionsByGatewayKey.set(t.gatewaySessionKey,t),t}async function callHistoryPage(e,s,t){const a={jsonrpc:"2.0",id:s,method:"_beeos/session/history",params:t};await e.handleRequest(a)}function getResult(e){const s=e.mock.calls.map(e=>e[0]).filter(e=>void 0!==e?.result);return expect(s).toHaveLength(1),s[0].result}function getNotifications(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e?.method)}function entry(e,s,t,a){return{role:e,content:[{type:"text",text:s}],timestamp:17e11+1e3*a,__openclaw:{id:t,seq:a}}}describe("_beeos/session/history — cursor pagination",()=>{it("returns the page updates synchronously and does NOT stream notifications",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","older question","aaaaaaaa",5),entry("assistant","older answer","bbbbbbbb",6)],nextCursor:"5",hasMore:!0}}));registerSession(e.state,"session:page-1"),await callHistoryPage(e.core,1,{sessionId:"session:page-1",cursor:"7"}),expect(getNotifications(e.sendBridgeMessage)).toHaveLength(0);const s=getResult(e.sendBridgeMessage);expect(s.hasMore).toBe(!0),expect(s.nextCursor).toBe("5");const t=s.updates;expect(Array.isArray(t)).toBe(!0);const a=t.map(e=>e.sessionUpdate);expect(a).toContain("user_message_chunk"),expect(a).toContain("agent_message_chunk"),expect(a).toContain("stream_complete")}),it("terminalizes orphan historical tool calls as interrupted",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","take a screenshot","aaaaaaaa",5),{role:"assistant",content:[{type:"tool_use",id:"tc-browser-snapshot",name:"browser",input:{action:"snapshot",targetId:"t1"}}],timestamp:170000001e4,__openclaw:{id:"bbbbbbbb",seq:6}}],hasMore:!1}}));registerSession(e.state,"session:orphan-tool"),await callHistoryPage(e.core,1,{sessionId:"session:orphan-tool"});const s=getResult(e.sendBridgeMessage).updates,t=s.find(e=>"tool_call_update"===e.sessionUpdate&&"tc-browser-snapshot"===e.toolCallId);expect(t).toMatchObject({sessionUpdate:"tool_call_update",toolCallId:"tc-browser-snapshot",status:"failed"});const a=s.find(e=>"stream_complete"===e.sessionUpdate);expect(a).toMatchObject({sessionUpdate:"stream_complete",stopReason:"interrupted",error:"The previous run was interrupted before it completed."});const o=e.logger.info.mock.calls.map(([e])=>String(e)).join("\n");expect(o).toContain("history_orphan_turn_terminalized"),expect(o).not.toContain("take a screenshot")}),it("terminalizes gateway lock failures without replaying raw diagnostics",async()=>{const e=buildCore(makeGateway({page:{messages:[{...entry("assistant","Agent failed before reply: session file locked: pid=357 /home/node/private/session.jsonl.lock token=secret","failure-id",7),metadata:{customType:"openclaw:prompt-error"}}],hasMore:!1}}));registerSession(e.state,"session:runtime-failure"),await callHistoryPage(e.core,1,{sessionId:"session:runtime-failure"});const s=getResult(e.sendBridgeMessage).updates;expect(JSON.stringify(s)).not.toContain("/home/node/private"),expect(JSON.stringify(s)).not.toContain("pid=357"),expect(JSON.stringify(s)).not.toContain("token=secret"),expect(s.find(e=>"stream_complete"===e.sessionUpdate)).toMatchObject({stopReason:"interrupted",error:"The previous run was interrupted before it completed."})}),it("tags updates with seq-namespaced _turnId and propagates _seq",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","hi","aaaaaaaa",42)],hasMore:!1}})),s=registerSession(e.state,"session:seqtag");await callHistoryPage(e.core,1,{sessionId:"session:seqtag"});const t=getResult(e.sendBridgeMessage).updates.find(e=>"user_message_chunk"===e.sessionUpdate);expect(t).toBeDefined(),expect(t?._turnId).toBe(`history:${s.gatewaySessionKey}:seq:42`),expect(t?._seq).toBe(42)}),it("filters out cron-triggered user turns (same as live replay)",async()=>{const e=buildCore(makeGateway({page:{messages:[entry("user","[cron:abcdef12 daily] run the report","cccccccc",10),entry("user","real question","dddddddd",11)],hasMore:!1}}));registerSession(e.state,"session:cron"),await callHistoryPage(e.core,1,{sessionId:"session:cron"});const s=getResult(e.sendBridgeMessage).updates.filter(e=>"user_message_chunk"===e.sessionUpdate).map(e=>e.content?.text);expect(s).toContain("real question"),expect(s.some(e=>e?.includes("[cron:"))).toBe(!1)}),it("forwards cursor and clamps limit to the hard cap",async()=>{let e={};const s=buildCore(makeGateway({page:{messages:[],hasMore:!1},onCall:(s,t)=>{e={key:s,args:t}}})),t=registerSession(s.state,"session:args");await callHistoryPage(s.core,1,{sessionId:"session:args",cursor:"99",limit:99999}),expect(e.key).toBe(t.gatewaySessionKey),expect(e.args?.cursor).toBe("99"),expect(e.args?.limit).toBe(1e3)}),it("returns an empty page (hasMore:false) when the gateway HTTP call fails",async()=>{const e=buildCore(makeGateway({page:null}));registerSession(e.state,"session:nopage"),await callHistoryPage(e.core,1,{sessionId:"session:nopage"});const s=getResult(e.sendBridgeMessage);expect(s.updates).toEqual([]),expect(s.hasMore).toBe(!1),expect(s.nextCursor).toBeUndefined()}),it("returns an empty page when no gateway is available",async()=>{const e=buildCore(null);registerSession(e.state,"session:nogw"),await callHistoryPage(e.core,1,{sessionId:"session:nogw"});const s=getResult(e.sendBridgeMessage);expect(s.updates).toEqual([]),expect(s.hasMore).toBe(!1)}),it("rejects a request with missing sessionId via sendAcpError",async()=>{const e=buildCore(makeGateway({page:{messages:[],hasMore:!1}}));await callHistoryPage(e.core,1,{});const s=e.sendBridgeMessage.mock.calls.map(e=>e[0]).filter(e=>void 0!==e?.error);expect(s).toHaveLength(1),expect(s[0].error.data?.param).toBe("sessionId")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeHarness(){const e=makeLogger(),t=vi.fn(),s=vi.fn(),o=new AcpGatewaySessionState(e),n=new AcpTransport(s,t),a=new AcpGatewayBridgeCore({state:o,transport:n,config:makeConfig(),logger:e,writeObs:t,getGateway:()=>null,instanceId:"test-instance"}),i={sessionId:"s1",gatewaySessionKey:"agent:main:s1",agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set};return o.sessions.set(i.sessionId,i),o.sessionsByGatewayKey.set(i.gatewaySessionKey,i),{core:a,session:i,transport:n,sendBridgeMessage:s,writeObs:t}}function sessionUpdates(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e.method).map(e=>e.params.update)}function screenshotToolResultContent(){return JSON.stringify({status:"completed",result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}async function flushAsyncToolResult(){await Promise.resolve(),await new Promise(e=>setImmediate(e)),await Promise.resolve()}describe("bridge-core persisted toolResult normalization",()=>{it("hydrates slim role=toolResult events from persisted local history before normalizing",async()=>{const e=makeHarness();e.transport.sendSessionUpdate(e.session.sessionId,{sessionUpdate:"tool_call",toolCallId:"tc-1",name:"beeos_get_task",args:{}},{turnId:"turn-1"}),e.core.localSessionHistory={readEntries:vi.fn().mockReturnValue([{role:"toolResult",toolCallId:"tc-1",toolName:"beeos_get_task",content:[{type:"text",text:JSON.stringify({content:screenshotToolResultContent()})}],details:{content:screenshotToolResultContent()},timestamp:Date.now(),__openclaw:{id:"tr-1"}}])},e.sendBridgeMessage.mockClear(),e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",messageSeq:9,message:{role:"toolResult",toolCallId:"tc-1",toolName:"beeos_get_task",content:[{type:"text",text:"Screenshot taken successfully. Here it is:"}]}}),await flushAsyncToolResult();const t=sessionUpdates(e.sendBridgeMessage);expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"tool_call_update",toolCallId:"tc-1",content:expect.arrayContaining([{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])})),expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}})),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.normalized",payload:expect.objectContaining({resourceLinkCount:1})}))}),it("normalizes independent role=toolResult messages into resource links",async()=>{const e=makeHarness();e.transport.sendSessionUpdate(e.session.sessionId,{sessionUpdate:"tool_call",toolCallId:"tc-1",name:"beeos_get_task",args:{}},{turnId:"turn-1"}),expect(e.session.toolCallTurns?.get("tc-1")).toEqual({turnId:"turn-1",toolName:"beeos_get_task"}),e.sendBridgeMessage.mockClear(),e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",messageSeq:9,message:{role:"toolResult",timestamp:Date.now(),content:[{type:"tool_result",tool_use_id:"tc-1",name:"beeos_get_task",content:screenshotToolResultContent()}]}}),await flushAsyncToolResult();const t=sessionUpdates(e.sendBridgeMessage);expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"tool_call_update",toolCallId:"tc-1",name:"beeos_get_task",_turnId:"turn-1",_seq:9,content:expect.arrayContaining([{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])})),expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"agent_message_chunk",_turnId:"turn-1",_seq:9,content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}})),expect(t).not.toContainEqual(expect.objectContaining({sessionUpdate:"message_id_bind"})),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.normalized",payload:expect.objectContaining({toolCallId:"tc-1",toolName:"beeos_get_task",resourceLinkCount:1})}))}),it("deduplicates the same persisted toolResult payload",async()=>{const e=makeHarness();e.transport.sendSessionUpdate(e.session.sessionId,{sessionUpdate:"tool_call",toolCallId:"tc-1",name:"beeos_get_task",args:{}},{turnId:"turn-1"}),expect(e.session.toolCallTurns?.get("tc-1")).toEqual({turnId:"turn-1",toolName:"beeos_get_task"}),e.sendBridgeMessage.mockClear();const t={sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",message:{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc-1",name:"beeos_get_task",content:screenshotToolResultContent()}]}};e.core.handleSessionMessage(t),e.core.handleSessionMessage(t),await flushAsyncToolResult();const s=sessionUpdates(e.sendBridgeMessage);expect(s.filter(e=>"tool_call_update"===e.sessionUpdate)).toHaveLength(1),expect(s.filter(e=>"agent_message_chunk"===e.sessionUpdate)).toHaveLength(1)}),it("skips independent toolResult messages without a toolCallId",async()=>{const e=makeHarness();e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",message:{role:"toolResult",details:{content:screenshotToolResultContent()}}}),await flushAsyncToolResult(),expect(sessionUpdates(e.sendBridgeMessage)).toHaveLength(0),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.skipped_no_tool_call_id"}))}),it("skips independent toolResult messages when no turn can own them",async()=>{const e=makeHarness();e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",message:{role:"toolResult",toolCallId:"tc-1",toolName:"beeos_get_task",details:{content:screenshotToolResultContent()}}}),await flushAsyncToolResult(),expect(sessionUpdates(e.sendBridgeMessage)).toHaveLength(0),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.skipped_no_turn",payload:expect.objectContaining({toolCallId:"tc-1"})}))})});
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";import{AcpTransport}from"./transport.js";function makeLogger(){return{info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()}}function makeConfig(){return{agentGatewayUrl:"http://localhost:0",keyFile:"/tmp/key",bridge:{url:"",service:"bridge",shell:{enabled:!1}},gateway:{url:"",token:"",protocol:1,clientId:"c",agentId:"main"},retry:{baseMs:100,maxMs:1e3,maxAttempts:0},log:{enabled:!1,verbose:!1,dir:"/tmp"},promptTimeoutMs:6e4,terminalWsPort:0,terminalWsAllowedOrigins:[],streamIdleTimeoutMs:6e4,streamStaleTimeoutMs:6e4,cronSignalWindowMs:5e3,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5,canvasRelayUrl:"",canvasEnabled:!1,openclawCanvasCompat:!1,messageBus:{enabled:!1,serviceUrl:"",url:"",principalId:"",a2aEnabled:!1}}}function makeHarness(){const e=makeLogger(),t=vi.fn(),s=vi.fn(),o=new AcpGatewaySessionState(e),n=new AcpTransport(s,t),a=new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:o,transport:n,config:makeConfig(),logger:e,writeObs:t,getGateway:()=>null,instanceId:"test-instance"}),i={sessionId:"s1",gatewaySessionKey:"agent:main:s1",agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set};return o.sessions.set(i.sessionId,i),o.sessionsByGatewayKey.set(i.gatewaySessionKey,i),{core:a,session:i,transport:n,sendBridgeMessage:s,writeObs:t}}function sessionUpdates(e){return e.mock.calls.map(e=>e[0]).filter(e=>"session/update"===e.method).map(e=>e.params.update)}function screenshotToolResultContent(){return JSON.stringify({status:"completed",result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}async function flushAsyncToolResult(){await Promise.resolve(),await new Promise(e=>setImmediate(e)),await Promise.resolve()}describe("bridge-core persisted toolResult normalization",()=>{it("hydrates slim role=toolResult events from persisted local history before normalizing",async()=>{const e=makeHarness();e.transport.sendSessionUpdate(e.session.sessionId,{sessionUpdate:"tool_call",toolCallId:"tc-1",name:"beeos_get_task",args:{}},{turnId:"turn-1"}),e.core.localSessionHistory={readEntries:vi.fn().mockReturnValue([{role:"toolResult",toolCallId:"tc-1",toolName:"beeos_get_task",content:[{type:"text",text:JSON.stringify({content:screenshotToolResultContent()})}],details:{content:screenshotToolResultContent()},timestamp:Date.now(),__openclaw:{id:"tr-1"}}])},e.sendBridgeMessage.mockClear(),e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",messageSeq:9,message:{role:"toolResult",toolCallId:"tc-1",toolName:"beeos_get_task",content:[{type:"text",text:"Screenshot taken successfully. Here it is:"}]}}),await flushAsyncToolResult();const t=sessionUpdates(e.sendBridgeMessage);expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"tool_call_update",toolCallId:"tc-1",content:expect.arrayContaining([{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])})),expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}})),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.normalized",payload:expect.objectContaining({resourceLinkCount:1})}))}),it("normalizes independent role=toolResult messages into resource links",async()=>{const e=makeHarness();e.transport.sendSessionUpdate(e.session.sessionId,{sessionUpdate:"tool_call",toolCallId:"tc-1",name:"beeos_get_task",args:{}},{turnId:"turn-1"}),expect(e.session.toolCallTurns?.get("tc-1")).toEqual({turnId:"turn-1",toolName:"beeos_get_task"}),e.sendBridgeMessage.mockClear(),e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",messageSeq:9,message:{role:"toolResult",timestamp:Date.now(),content:[{type:"tool_result",tool_use_id:"tc-1",name:"beeos_get_task",content:screenshotToolResultContent()}]}}),await flushAsyncToolResult();const t=sessionUpdates(e.sendBridgeMessage);expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"tool_call_update",toolCallId:"tc-1",name:"beeos_get_task",_turnId:"turn-1",_seq:9,content:expect.arrayContaining([{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])})),expect(t).toContainEqual(expect.objectContaining({sessionUpdate:"agent_message_chunk",_turnId:"turn-1",_seq:9,content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}})),expect(t).not.toContainEqual(expect.objectContaining({sessionUpdate:"message_id_bind"})),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.normalized",payload:expect.objectContaining({toolCallId:"tc-1",toolName:"beeos_get_task",resourceLinkCount:1})}))}),it("deduplicates the same persisted toolResult payload",async()=>{const e=makeHarness();e.transport.sendSessionUpdate(e.session.sessionId,{sessionUpdate:"tool_call",toolCallId:"tc-1",name:"beeos_get_task",args:{}},{turnId:"turn-1"}),expect(e.session.toolCallTurns?.get("tc-1")).toEqual({turnId:"turn-1",toolName:"beeos_get_task"}),e.sendBridgeMessage.mockClear();const t={sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",message:{role:"toolResult",content:[{type:"tool_result",tool_use_id:"tc-1",name:"beeos_get_task",content:screenshotToolResultContent()}]}};e.core.handleSessionMessage(t),e.core.handleSessionMessage(t),await flushAsyncToolResult();const s=sessionUpdates(e.sendBridgeMessage);expect(s.filter(e=>"tool_call_update"===e.sessionUpdate)).toHaveLength(1),expect(s.filter(e=>"agent_message_chunk"===e.sessionUpdate)).toHaveLength(1)}),it("skips independent toolResult messages without a toolCallId",async()=>{const e=makeHarness();e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",message:{role:"toolResult",details:{content:screenshotToolResultContent()}}}),await flushAsyncToolResult(),expect(sessionUpdates(e.sendBridgeMessage)).toHaveLength(0),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.skipped_no_tool_call_id"}))}),it("skips independent toolResult messages when no turn can own them",async()=>{const e=makeHarness();e.core.handleSessionMessage({sessionKey:e.session.gatewaySessionKey,messageId:"tr-1",message:{role:"toolResult",toolCallId:"tc-1",toolName:"beeos_get_task",details:{content:screenshotToolResultContent()}}}),await flushAsyncToolResult(),expect(sessionUpdates(e.sendBridgeMessage)).toHaveLength(0),expect(e.writeObs).toHaveBeenCalledWith(expect.objectContaining({name:"tool_result.skipped_no_turn",payload:expect.objectContaining({toolCallId:"tc-1"})}))})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),s={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),s={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o={isConnected:!0,sessionsPatch:vi.fn().mockResolvedValue(void 0),chatHistory:vi.fn().mockResolvedValue(null),sessionsMessagesSubscribe:vi.fn().mockResolvedValue(void 0),sessionsList:vi.fn().mockResolvedValue({sessions:[]})};return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:t,transport:s,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>o,instanceId:"test-instance",thirdPartyChannels:new Set}),state:t,transport:s,gateway:o,logger:e}}describe("handleRequest — ping keep-alive (Bug-X)",()=>{let e;async function t(t){await e.bridge.handleRequest(t),await Promise.resolve()}beforeEach(()=>{e=makeBridge()}),it("responds to ping with a successful result, NOT a MethodNotFound error",async()=>{await t({jsonrpc:"2.0",id:42,method:"ping",params:{}}),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.transport.sendResult).toHaveBeenCalledTimes(1);const[s,o]=e.transport.sendResult.mock.calls[0];expect(s).toBe(42),expect(o).toEqual({pong:!0})}),it("accepts ping with no params (SDK sends empty object, but also absent)",async()=>{await t({jsonrpc:"2.0",id:"ka-1",method:"ping"}),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.transport.sendResult).toHaveBeenCalledTimes(1),expect(e.transport.sendResult.mock.calls[0]?.[1]).toEqual({pong:!0})}),it("is stateless — does not touch session state or gateway",async()=>{const s=e.transport.setSessionUpdateObserver.mock.calls.length,o=e.transport.sendNotification.mock.calls.length;await t({jsonrpc:"2.0",id:1,method:"ping",params:{}}),expect(e.state.sessions.size).toBe(0),expect(e.gateway.sessionsPatch).not.toHaveBeenCalled(),expect(e.gateway.sessionsMessagesSubscribe).not.toHaveBeenCalled(),expect(e.gateway.sessionsList).not.toHaveBeenCalled(),expect(e.gateway.chatHistory).not.toHaveBeenCalled(),expect(e.transport.setSessionUpdateObserver.mock.calls.length).toBe(s),expect(e.transport.sendNotification.mock.calls.length).toBe(o)}),it("handles high-frequency pings (SDK fires every 30s; simulate a burst)",async()=>{for(let e=0;e<10;e++)await t({jsonrpc:"2.0",id:e,method:"ping",params:{}});expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.transport.sendResult).toHaveBeenCalledTimes(10);for(let t=0;t<10;t++){const[s,o]=e.transport.sendResult.mock.calls[t];expect(s).toBe(t),expect(o).toEqual({pong:!0})}}),it("unknown methods still emit MethodNotFound (guardrail for the switch default)",async()=>{await t({jsonrpc:"2.0",id:"unknown",method:"definitely/not/real",params:{}}),expect(e.transport.sendResult).not.toHaveBeenCalled(),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1);const[s,o]=e.transport.sendAcpError.mock.calls[0];expect(s).toBe("unknown"),expect(o).toMatchObject({rpcCode:-32601})})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{afterEach,describe,expect,it,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";const deferred=()=>{let e;return{promise:new Promise(t=>{e=t}),resolve:e}},makeBridge=(e,t)=>{const n={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},o=new AcpGatewaySessionState(n),s={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(()=>{if("transport"===t)throw new Error("transport failed");return!0}),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a={isConnected:!0,chatAbort:vi.fn().mockResolvedValue(void 0),sessionsDelete:vi.fn().mockResolvedValue(void 0),request:vi.fn().mockResolvedValue({})},r=createTestSessionMutationCoordinator({},e),i=new AcpGatewayBridgeCore({sessionMutationCoordinator:r.coordinator,state:o,transport:s,config:{gateway:{agentId:"main"},promptTimeoutMs:10,cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:18e5},logger:n,writeObs:vi.fn(),getGateway:()=>a,instanceId:"test-instance"});return"hook"===t&&(i.onRunComplete=()=>{throw new Error("hook failed")}),{bridge:i,state:o,transport:s,gateway:a,mutation:r}},prepareRun=async(e,t)=>{const n=e.bridge.ensureSessionForKey(t),o=e.mutation.coordinator.tryStartInteractive({sessionKey:t},"acp");if(!o.ok)throw new Error("expected mutation lease");return n.writeGateToken=o.token,n.pendingPromptId="prompt-1",n.currentRunId="run-1",n.promptDone=!1,e.mutation.coordinator.bindRun(o.token,"run-1"),n};describe("AcpGatewayBridgeCore session mutation lifecycle",()=>{afterEach(()=>vi.useRealTimers()),it("clears timeout and idle timers before waiting for terminal quiescence",async()=>{vi.useFakeTimers();const e=deferred(),t=makeBridge({waitForQuiescence:async()=>(await e.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})}),n=await prepareRun(t,"agent:main:timer-race");t.bridge.schedulePromptTimeout(n),n.streamIdleTimer=setTimeout(()=>{},10);const o=t.bridge.endRun(n,{kind:"complete",stopReason:"end_turn"});expect(n.promptTimeoutTimer).toBeUndefined(),expect(n.streamIdleTimer).toBeUndefined(),await vi.advanceTimersByTimeAsync(20),expect(t.gateway.chatAbort).not.toHaveBeenCalled(),e.resolve(),await expect(o).resolves.toBe(!0)}),it("uses the timeout override as the coordinator terminal kind",async()=>{const e=deferred(),t=makeBridge({waitForQuiescence:async()=>(await e.promise,{quiescent:!0,reason:"unlocked",elapsedMs:1})}),n=await prepareRun(t,"agent:main:timeout-kind");n.mutationTerminalOverride="timeout";const o=t.bridge.endRun(n,{kind:"complete",stopReason:"cancelled"});await Promise.resolve(),expect(t.mutation.coordinator.snapshot(n.gatewaySessionKey)?.terminalKind).toBe("timeout"),e.resolve(),await expect(o).resolves.toBe(!0)}),it.each(["transport","hook"])("cleans up and releases the permit when %s publication throws",async e=>{const t=makeBridge(void 0,e),n=`agent:main:${e}-failure`,o=await prepareRun(t,n),s=vi.fn();t.bridge.onRunEnded=s,await expect(t.bridge.endRun(o,{kind:"complete",stopReason:"end_turn"})).rejects.toThrow(`${e} failed`),expect(o.currentRunId).toBeUndefined(),expect(o.pendingPromptId).toBeUndefined(),expect(t.mutation.gate.snapshot(n).active).toBeUndefined(),expect(s).toHaveBeenCalledTimes(1)}),it("does not release the Message Service owner when an ACP observer ends first",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:observer-first"),n=e.mutation.coordinator.tryStartInteractive({sessionKey:t.gatewaySessionKey},"message_service");if(!n.ok)throw new Error("expected Message Service owner");e.mutation.coordinator.bindRun(n.token,"run-shared");const o=e.bridge.adoptBackgroundMutation(t,"run-shared");expect(o).toMatchObject({ok:!0,ownership:"observer"}),expect(t.writeGateToken).toBeUndefined(),t.pendingPromptId="observer-prompt",t.currentRunId="run-shared",t.promptDone=!1,await expect(e.bridge.endRun(t,{kind:"complete",stopReason:"end_turn"})).resolves.toBe(!0),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)).toMatchObject({operationId:n.token.operationId,phase:"active",surface:"message_service"}),expect(e.mutation.gate.snapshot(t.gatewaySessionKey).active?.operationId).toBe(n.token.operationId)}),it("cancels the Message Service owner from an ACP observer session",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:observer-cancel"),n=e.mutation.coordinator.tryStartInteractive({sessionKey:t.gatewaySessionKey},"message_service");if(!n.ok)throw new Error("expected Message Service owner");e.mutation.coordinator.bindRun(n.token,"run-ms-owner");const o=e.bridge.adoptBackgroundMutation(t,"run-ms-owner");expect(o).toMatchObject({ok:!0,ownership:"observer"}),expect(t.writeGateToken).toBeUndefined(),t.currentRunId="run-ms-owner",t.promptDone=!1,await e.bridge.handleSessionCancel("cancel-observer",{sessionId:t.sessionId}),expect(e.gateway.chatAbort).toHaveBeenCalledWith(t.gatewaySessionKey,"run-ms-owner"),expect(e.transport.sendResult).toHaveBeenCalledWith("cancel-observer",{}),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)).toMatchObject({operationId:n.token.operationId,phase:"cancelling",cancelAcknowledged:!0}),expect(t.writeGateToken).toBeUndefined();const s=await e.mutation.coordinator.converge(n.token,"cancelled");s.ok&&e.mutation.coordinator.release(s.permit)}),it.each([{kind:"request",id:"cancel-no-owner"},{kind:"notification",id:void 0}])("treats connected session cancel with no owner as an idempotent $kind",async({id:e})=>{const t=makeBridge(),n=t.bridge.ensureSessionForKey("agent:main:cancel-no-owner");await t.bridge.handleSessionCancel(e,{sessionId:n.sessionId}),expect(t.gateway.chatAbort).not.toHaveBeenCalled(),expect(t.transport.sendError).not.toHaveBeenCalled(),expect(t.transport.sendAcpError).not.toHaveBeenCalled(),void 0===e?expect(t.transport.sendResult).not.toHaveBeenCalled():expect(t.transport.sendResult).toHaveBeenCalledWith(e,{})}),it.each(["currentRunId","internalOwner"])("rejects session/delete as busy for an active %s mutation",async e=>{const t=makeBridge(),n=t.bridge.ensureSessionForKey(`agent:main:delete-${e}`);"currentRunId"===e?(n.currentRunId="run-active",n.promptDone=!1):await t.mutation.coordinator.acquireInternal({sessionKey:n.gatewaySessionKey,label:"internal-test"}),await t.bridge.handleRequest({jsonrpc:"2.0",id:"delete-1",method:"session/delete",params:{sessionId:n.sessionId}}),expect(t.transport.sendError).toHaveBeenCalledWith("delete-1",expect.any(Number),"Agent is busy",expect.objectContaining({code:"session_busy",reason:"active_session_mutation"})),expect(t.gateway.sessionsDelete).not.toHaveBeenCalled(),expect(t.state.sessions.get(n.sessionId)).toBe(n)}),it("holds the session mutation lease across remote and local delete",async()=>{const e=deferred(),t=makeBridge(),n=t.bridge.ensureSessionForKey("agent:main:delete-interleaving");t.gateway.sessionsDelete.mockImplementationOnce(async()=>{await e.promise});const o=t.bridge.handleRequest({jsonrpc:"2.0",id:"delete-interleaving",method:"session/delete",params:{sessionId:n.sessionId}});await vi.waitFor(()=>expect(t.gateway.sessionsDelete).toHaveBeenCalledTimes(1));const s=t.mutation.coordinator.snapshot(n.gatewaySessionKey);expect(s).toMatchObject({attachmentPolicy:"exclusive",phase:"active"}),expect(t.mutation.coordinator.attachOrStartExternal({sessionKey:n.gatewaySessionKey},"message_service","run-during-delete")).toEqual({ok:!1,reason:"session_busy"}),expect(t.mutation.coordinator.snapshot(n.gatewaySessionKey)).toEqual(s),expect(t.state.sessions.get(n.sessionId)).toBe(n),await t.bridge.handleSessionCancel("cancel-during-delete",{sessionId:n.sessionId}),expect(t.gateway.chatAbort).not.toHaveBeenCalled(),expect(t.transport.sendError).toHaveBeenCalledWith("cancel-during-delete",expect.any(Number),"Agent is busy",expect.objectContaining({code:"session_busy",reason:"exclusive_session_mutation"})),expect(t.mutation.coordinator.snapshot(n.gatewaySessionKey)).toEqual(s),e.resolve(),await o,expect(t.state.sessions.has(n.sessionId)).toBe(!1),expect(t.mutation.gate.snapshot(n.gatewaySessionKey).active).toBeUndefined()}),it("releases the delete lease when the remote delete fails",async()=>{const e=makeBridge(),t=e.bridge.ensureSessionForKey("agent:main:delete-failure-release");e.gateway.sessionsDelete.mockRejectedValueOnce(new Error("delete rejected")),await e.bridge.handleRequest({jsonrpc:"2.0",id:"delete-failure-release",method:"session/delete",params:{sessionId:t.sessionId}}),expect(e.transport.sendResult).toHaveBeenCalledWith("delete-failure-release",{deleted:!1}),expect(e.state.sessions.get(t.sessionId)).toBe(t),expect(e.mutation.coordinator.tryStartInteractive({sessionKey:t.gatewaySessionKey},"message_service")).toMatchObject({ok:!0})}),it("returns recovery_required instead of success when chat.abort ACK fails",async()=>{const e=makeBridge(),t=await prepareRun(e,"agent:main:abort-ack-failed");e.gateway.chatAbort.mockRejectedValueOnce(new Error("abort rejected")),await e.bridge.handleSessionCancel("cancel-1",{sessionId:t.sessionId}),expect(e.transport.sendError).toHaveBeenCalledWith("cancel-1",expect.any(Number),expect.any(String),expect.objectContaining({code:"session_recovery_required"})),expect(e.transport.sendResult).not.toHaveBeenCalledWith("cancel-1",{}),expect(e.mutation.coordinator.snapshot(t.gatewaySessionKey)?.phase).toBe("recovery_required")})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},s=new AcpGatewaySessionState(e),t={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,sessionsPatch:vi.fn().mockResolvedValue(void 0),chatHistory:vi.fn().mockResolvedValue(null),sessionsMessagesSubscribe:vi.fn().mockResolvedValue(void 0),sessionsList:vi.fn().mockResolvedValue({sessions:[]})};return{bridge:new AcpGatewayBridgeCore({state:s,transport:t,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set}),state:s,transport:t,gateway:n,logger:e}}describe("handleSessionNew — lazy verbose patch (stub prevention)",()=>{let e;async function s(s={}){const t={jsonrpc:"2.0",id:1,method:"session/new",params:s};await e.bridge.handleSessionNew(t),await Promise.resolve(),await Promise.resolve();const n=e.transport.sendResult.mock.calls[0]?.[1];if(!n?.sessionId)throw new Error("session/new did not call sendResult with a sessionId");return n.sessionId}beforeEach(()=>{e=makeBridge()}),it("registers the new session in both lookup maps",async()=>{const t=await s();expect(e.state.sessions.has(t)).toBe(!0);const n=e.state.sessions.get(t);expect(n.gatewaySessionKey).toMatch(/^agent:main:/),expect(e.state.sessionsByGatewayKey.get(n.gatewaySessionKey)).toBe(n)}),it("responds to the RPC with the derived sessionId and sessionKey meta",async()=>{await s(),expect(e.transport.sendResult).toHaveBeenCalledTimes(1);const[t,n]=e.transport.sendResult.mock.calls[0];expect(t).toBe(1),expect(n).toMatchObject({sessionId:expect.stringMatching(/^session:/),_meta:{sessionKey:expect.stringMatching(/^agent:main:/),agentId:"main"}})}),it("does NOT eagerly call sessions.patch — this is the regression we fixed",async()=>{await s(),expect(e.gateway.sessionsPatch).not.toHaveBeenCalled()}),it("still subscribes to session.message events (needed for future prompts)",async()=>{await s(),expect(e.gateway.sessionsMessagesSubscribe).toHaveBeenCalledTimes(1);const[t]=e.gateway.sessionsMessagesSubscribe.mock.calls[0];expect(t).toMatch(/^agent:main:/)}),it("each session/new produces a unique gatewaySessionKey",async()=>{const t=await s(),n=e.state.sessions.get(t).gatewaySessionKey;e.transport.sendResult.mockClear();await e.bridge.handleSessionNew({jsonrpc:"2.0",id:2,method:"session/new"}),await Promise.resolve(),await Promise.resolve();const
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{createTestSessionMutationCoordinator}from"../test-fixtures/session-mutation.fixture.js";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},s=new AcpGatewaySessionState(e),t={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,sessionsPatch:vi.fn().mockResolvedValue(void 0),chatHistory:vi.fn().mockResolvedValue(null),sessionsMessagesSubscribe:vi.fn().mockResolvedValue(void 0),sessionsList:vi.fn().mockResolvedValue({sessions:[]})};return{bridge:new AcpGatewayBridgeCore({sessionMutationCoordinator:createTestSessionMutationCoordinator().coordinator,state:s,transport:t,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set}),state:s,transport:t,gateway:n,logger:e}}describe("handleSessionNew — lazy verbose patch (stub prevention)",()=>{let e;async function s(s={}){const t={jsonrpc:"2.0",id:1,method:"session/new",params:s};await e.bridge.handleSessionNew(t),await Promise.resolve(),await Promise.resolve();const n=e.transport.sendResult.mock.calls[0]?.[1];if(!n?.sessionId)throw new Error("session/new did not call sendResult with a sessionId");return n.sessionId}beforeEach(()=>{e=makeBridge()}),it("registers the new session in both lookup maps",async()=>{const t=await s();expect(e.state.sessions.has(t)).toBe(!0);const n=e.state.sessions.get(t);expect(n.gatewaySessionKey).toMatch(/^agent:main:/),expect(e.state.sessionsByGatewayKey.get(n.gatewaySessionKey)).toBe(n)}),it("responds to the RPC with the derived sessionId and sessionKey meta",async()=>{await s(),expect(e.transport.sendResult).toHaveBeenCalledTimes(1);const[t,n]=e.transport.sendResult.mock.calls[0];expect(t).toBe(1),expect(n).toMatchObject({sessionId:expect.stringMatching(/^session:/),_meta:{sessionKey:expect.stringMatching(/^agent:main:/),agentId:"main"}})}),it("does NOT eagerly call sessions.patch — this is the regression we fixed",async()=>{await s(),expect(e.gateway.sessionsPatch).not.toHaveBeenCalled()}),it("still subscribes to session.message events (needed for future prompts)",async()=>{await s(),expect(e.gateway.sessionsMessagesSubscribe).toHaveBeenCalledTimes(1);const[t]=e.gateway.sessionsMessagesSubscribe.mock.calls[0];expect(t).toMatch(/^agent:main:/)}),it("each session/new produces a unique gatewaySessionKey",async()=>{const t=await s(),n=e.state.sessions.get(t).gatewaySessionKey;e.transport.sendResult.mockClear();await e.bridge.handleSessionNew({jsonrpc:"2.0",id:2,method:"session/new"}),await Promise.resolve(),await Promise.resolve();const o=e.transport.sendResult.mock.calls[0]?.[1],a=e.state.sessions.get(o.sessionId).gatewaySessionKey;expect(a).not.toBe(n),expect(e.gateway.sessionsPatch).not.toHaveBeenCalled()})});
|