@@ -4037,6 +4037,241 @@ describe("QmdMemoryManager", () => {
40374037 await manager . close ( ) ;
40384038 } ) ;
40394039
4040+ it ( "keeps configured stdio qmd with keep-alive lifecycle external" , async ( ) => {
4041+ const userMcporterConfig = path . join ( tmpRoot , "stdio-lifecycle-user-mcporter.json" ) ;
4042+ process . env . MCPORTER_CONFIG = userMcporterConfig ;
4043+ await fs . writeFile (
4044+ userMcporterConfig ,
4045+ JSON . stringify ( {
4046+ mcpServers : {
4047+ "custom-qmd" : {
4048+ command : "qmd" ,
4049+ args : [ "mcp" ] ,
4050+ lifecycle : { mode : "keep-alive" , idleTimeoutMs : 60_000 } ,
4051+ } ,
4052+ } ,
4053+ } ) ,
4054+ "utf8" ,
4055+ ) ;
4056+
4057+ cfg = {
4058+ ...cfg ,
4059+ memory : {
4060+ backend : "qmd" ,
4061+ qmd : {
4062+ includeDefaultMemory : false ,
4063+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
4064+ paths : [ { path : workspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
4065+ mcporter : { enabled : true , serverName : "custom-qmd" , startDaemon : false } ,
4066+ } ,
4067+ } ,
4068+ } as OpenClawConfig ;
4069+
4070+ spawnMock . mockImplementation ( ( cmd : string , args : string [ ] ) => {
4071+ const child = createMockChild ( { autoClose : false } ) ;
4072+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "config" ) {
4073+ emitAndClose (
4074+ child ,
4075+ "stdout" ,
4076+ JSON . stringify ( {
4077+ name : "custom-qmd" ,
4078+ source : "user" ,
4079+ command : "qmd" ,
4080+ args : [ "mcp" ] ,
4081+ } ) ,
4082+ ) ;
4083+ return child ;
4084+ }
4085+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "call" ) {
4086+ emitAndClose ( child , "stdout" , JSON . stringify ( { results : [ ] } ) ) ;
4087+ return child ;
4088+ }
4089+ emitAndClose ( child , "stdout" , "[]" ) ;
4090+ return child ;
4091+ } ) ;
4092+
4093+ const { manager } = await createManager ( ) ;
4094+ await manager . search ( "hello" , { sessionKey : "agent:main:slack:dm:u123" } ) ;
4095+
4096+ const mcporterCall = spawnMock . mock . calls . find (
4097+ ( call : unknown [ ] ) => isMcporterCommand ( call [ 0 ] ) && ( call [ 1 ] as string [ ] ) [ 0 ] === "call" ,
4098+ ) ;
4099+ const args = ( requireValue ( mcporterCall , "mcporter search call missing" ) [ 1 ] ?? [ ] ) as string [ ] ;
4100+ expect ( args ) . not . toContain ( "--config" ) ;
4101+ const callOpts = mcporterCall ?. [ 2 ] as { env ?: NodeJS . ProcessEnv } | undefined ;
4102+ expect ( callOpts ?. env ?. MCPORTER_CONFIG ) . toBe ( userMcporterConfig ) ;
4103+ await expect (
4104+ fs . stat ( path . join ( stateDir , "agents" , "main" , "qmd" , "mcporter" , "mcporter.json" ) ) ,
4105+ ) . rejects . toMatchObject ( { code : "ENOENT" } ) ;
4106+
4107+ await manager . close ( ) ;
4108+ } ) ;
4109+
4110+ it ( "keeps configured stdio qmd with daemon logging external" , async ( ) => {
4111+ const userMcporterConfig = path . join ( tmpRoot , "stdio-logging-user-mcporter.json" ) ;
4112+ process . env . MCPORTER_CONFIG = userMcporterConfig ;
4113+ await fs . writeFile (
4114+ userMcporterConfig ,
4115+ JSON . stringify ( {
4116+ mcpServers : {
4117+ "custom-qmd" : {
4118+ command : "qmd" ,
4119+ args : [ "mcp" ] ,
4120+ logging : { enabled : true , level : "info" } ,
4121+ } ,
4122+ } ,
4123+ } ) ,
4124+ "utf8" ,
4125+ ) ;
4126+
4127+ cfg = {
4128+ ...cfg ,
4129+ memory : {
4130+ backend : "qmd" ,
4131+ qmd : {
4132+ includeDefaultMemory : false ,
4133+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
4134+ paths : [ { path : workspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
4135+ mcporter : { enabled : true , serverName : "custom-qmd" , startDaemon : false } ,
4136+ } ,
4137+ } ,
4138+ } as OpenClawConfig ;
4139+
4140+ spawnMock . mockImplementation ( ( cmd : string , args : string [ ] ) => {
4141+ const child = createMockChild ( { autoClose : false } ) ;
4142+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "config" ) {
4143+ emitAndClose (
4144+ child ,
4145+ "stdout" ,
4146+ JSON . stringify ( {
4147+ name : "custom-qmd" ,
4148+ source : "user" ,
4149+ command : "qmd" ,
4150+ args : [ "mcp" ] ,
4151+ } ) ,
4152+ ) ;
4153+ return child ;
4154+ }
4155+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "call" ) {
4156+ emitAndClose ( child , "stdout" , JSON . stringify ( { results : [ ] } ) ) ;
4157+ return child ;
4158+ }
4159+ emitAndClose ( child , "stdout" , "[]" ) ;
4160+ return child ;
4161+ } ) ;
4162+
4163+ const { manager } = await createManager ( ) ;
4164+ await manager . search ( "hello" , { sessionKey : "agent:main:slack:dm:u123" } ) ;
4165+
4166+ const mcporterCall = spawnMock . mock . calls . find (
4167+ ( call : unknown [ ] ) => isMcporterCommand ( call [ 0 ] ) && ( call [ 1 ] as string [ ] ) [ 0 ] === "call" ,
4168+ ) ;
4169+ const args = ( requireValue ( mcporterCall , "mcporter search call missing" ) [ 1 ] ?? [ ] ) as string [ ] ;
4170+ expect ( args ) . not . toContain ( "--config" ) ;
4171+ const callOpts = mcporterCall ?. [ 2 ] as { env ?: NodeJS . ProcessEnv } | undefined ;
4172+ expect ( callOpts ?. env ?. MCPORTER_CONFIG ) . toBe ( userMcporterConfig ) ;
4173+ await expect (
4174+ fs . stat ( path . join ( stateDir , "agents" , "main" , "qmd" , "mcporter" , "mcporter.json" ) ) ,
4175+ ) . rejects . toMatchObject ( { code : "ENOENT" } ) ;
4176+
4177+ await manager . close ( ) ;
4178+ } ) ;
4179+
4180+ it ( "tolerates warm-daemon log lines before JSON" , async ( ) => {
4181+ cfg = {
4182+ ...cfg ,
4183+ memory : {
4184+ backend : "qmd" ,
4185+ qmd : {
4186+ includeDefaultMemory : false ,
4187+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
4188+ paths : [ { path : workspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
4189+ mcporter : { enabled : true , serverName : "qmd" , startDaemon : false } ,
4190+ } ,
4191+ } ,
4192+ } as OpenClawConfig ;
4193+
4194+ spawnMock . mockImplementation ( ( cmd : string , args : string [ ] ) => {
4195+ const child = createMockChild ( { autoClose : false } ) ;
4196+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "call" ) {
4197+ emitAndClose ( child , "stdout" , '[info] daemon warm\n{"results": []}' ) ;
4198+ return child ;
4199+ }
4200+ emitAndClose ( child , "stdout" , "[]" ) ;
4201+ return child ;
4202+ } ) ;
4203+
4204+ const { manager } = await createManager ( ) ;
4205+ const results = await manager . search ( "hello" , { sessionKey : "agent:main:slack:dm:u123" } ) ;
4206+ expect ( results ) . toEqual ( [ ] ) ;
4207+
4208+ await manager . close ( ) ;
4209+ } ) ;
4210+
4211+ it ( "does not misparse log lines containing { and [" , async ( ) => {
4212+ cfg = {
4213+ ...cfg ,
4214+ memory : {
4215+ backend : "qmd" ,
4216+ qmd : {
4217+ includeDefaultMemory : false ,
4218+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
4219+ paths : [ { path : workspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
4220+ mcporter : { enabled : true , serverName : "qmd" , startDaemon : false } ,
4221+ } ,
4222+ } ,
4223+ } as OpenClawConfig ;
4224+
4225+ spawnMock . mockImplementation ( ( cmd : string , args : string [ ] ) => {
4226+ const child = createMockChild ( { autoClose : false } ) ;
4227+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "call" ) {
4228+ emitAndClose ( child , "stdout" , 'log: {foo: [bar]}\n{"results": []}' ) ;
4229+ return child ;
4230+ }
4231+ emitAndClose ( child , "stdout" , "[]" ) ;
4232+ return child ;
4233+ } ) ;
4234+
4235+ const { manager } = await createManager ( ) ;
4236+ const results = await manager . search ( "hello" , { sessionKey : "agent:main:slack:dm:u123" } ) ;
4237+ expect ( results ) . toEqual ( [ ] ) ;
4238+
4239+ await manager . close ( ) ;
4240+ } ) ;
4241+
4242+ it ( "parses JSON at end of multi-line prefix" , async ( ) => {
4243+ cfg = {
4244+ ...cfg ,
4245+ memory : {
4246+ backend : "qmd" ,
4247+ qmd : {
4248+ includeDefaultMemory : false ,
4249+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
4250+ paths : [ { path : workspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
4251+ mcporter : { enabled : true , serverName : "qmd" , startDaemon : false } ,
4252+ } ,
4253+ } ,
4254+ } as OpenClawConfig ;
4255+
4256+ spawnMock . mockImplementation ( ( cmd : string , args : string [ ] ) => {
4257+ const child = createMockChild ( { autoClose : false } ) ;
4258+ if ( isMcporterCommand ( cmd ) && args [ 0 ] === "call" ) {
4259+ emitAndClose ( child , "stdout" , 'line 1\nline 2\n{"results": []}' ) ;
4260+ return child ;
4261+ }
4262+ emitAndClose ( child , "stdout" , "[]" ) ;
4263+ return child ;
4264+ } ) ;
4265+
4266+ const { manager } = await createManager ( ) ;
4267+ // Should not throw despite the log prefix before the JSON body.
4268+ await expect (
4269+ manager . search ( "hello" , { sessionKey : "agent:main:slack:dm:u123" } ) ,
4270+ ) . resolves . toEqual ( [ ] ) ;
4271+
4272+ await manager . close ( ) ;
4273+ } ) ;
4274+
40404275 it ( "generates agent-scoped config for absolute qmd stdio servers" , async ( ) => {
40414276 const absoluteQmdCommand = path . join ( tmpRoot , "node" , "v24.15.0" , "bin" , "qmd" ) ;
40424277
0 commit comments