@@ -5848,6 +5848,170 @@ describe("QmdMemoryManager", () => {
58485848 }
58495849 } ) ;
58505850 } ) ;
5851+
5852+ it ( "rebinds a managed collection when its root path changed (show reveals old path)" , async ( ) => {
5853+ // Regression: listCollectionsBestEffort gets only the name from `collection list`
5854+ // (no path). The fix enriches path via `collection show`; without it shouldRebindCollection
5855+ // hits the `!listed.path` branch and skips the rebind, leaving the old path pinned.
5856+ const oldWorkspaceDir = path . join ( tmpRoot , "old-workspace" ) ;
5857+ const newWorkspaceDir = workspaceDir ; // the manager is configured for this new path
5858+
5859+ cfg = {
5860+ ...cfg ,
5861+ memory : {
5862+ backend : "qmd" ,
5863+ qmd : {
5864+ includeDefaultMemory : false ,
5865+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
5866+ paths : [ { path : newWorkspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
5867+ } ,
5868+ } ,
5869+ } as OpenClawConfig ;
5870+
5871+ const collectionName = `workspace-${ agentId } ` ;
5872+
5873+ spawnMock . mockImplementation ( ( _cmd : string , args : string [ ] ) => {
5874+ if ( args [ 0 ] === "collection" && args [ 1 ] === "list" ) {
5875+ // Real qmd: names only, no path/pattern in list output.
5876+ const child = createMockChild ( { autoClose : false } ) ;
5877+ emitAndClose ( child , "stdout" , JSON . stringify ( [ collectionName ] ) ) ;
5878+ return child ;
5879+ }
5880+ if ( args [ 0 ] === "collection" && args [ 1 ] === "show" && args [ 2 ] === collectionName ) {
5881+ // Real qmd `collection show` output — exposes the stale (old) path.
5882+ const child = createMockChild ( { autoClose : false } ) ;
5883+ emitAndClose (
5884+ child ,
5885+ "stdout" ,
5886+ [
5887+ `Collection: ${ collectionName } ` ,
5888+ ` Path: ${ oldWorkspaceDir } ` ,
5889+ ` Pattern: **/*.md` ,
5890+ ` Include: yes (default)` ,
5891+ ] . join ( "\n" ) ,
5892+ ) ;
5893+ return child ;
5894+ }
5895+ return createMockChild ( ) ;
5896+ } ) ;
5897+
5898+ const { manager } = await createManager ( { mode : "full" } ) ;
5899+ await manager . close ( ) ;
5900+
5901+ const commands = spawnMock . mock . calls . map ( ( call : unknown [ ] ) => call [ 1 ] as string [ ] ) ;
5902+
5903+ const removeCall = commands . find (
5904+ ( args ) => args [ 0 ] === "collection" && args [ 1 ] === "remove" && args [ 2 ] === collectionName ,
5905+ ) ;
5906+ expect ( removeCall ) . toBeDefined ( ) ; // rebind must remove the stale collection
5907+
5908+ const addCall = commands . find ( ( args ) => {
5909+ if ( args [ 0 ] !== "collection" || args [ 1 ] !== "add" ) {
5910+ return false ;
5911+ }
5912+ const nameIdx = args . indexOf ( "--name" ) ;
5913+ return nameIdx >= 0 && args [ nameIdx + 1 ] === collectionName ;
5914+ } ) ;
5915+ expect ( addCall ) . toBeDefined ( ) ;
5916+ // The new add must target the NEW workspace path, not the old one.
5917+ expect ( addCall ?. [ 2 ] ) . toBe ( newWorkspaceDir ) ;
5918+ } ) ;
5919+
5920+ it ( "rebinds a stale in-container collection root to the host workspace (sandbox-mode transition)" , async ( ) => {
5921+ // Sandbox coverage: an agent that previously ran with its workspace bind-mounted under
5922+ // /home/node/.openclaw/... stored that in-container path as the collection root. Resolved
5923+ // with host paths, `collection show` reveals the stale container path; the rebind is
5924+ // path-namespace-agnostic and re-binds to the current host root.
5925+ const containerRoot = "/home/node/.openclaw/teams/x/workspace" ;
5926+ const newWorkspaceDir = workspaceDir ; // host path the manager is configured for
5927+
5928+ cfg = {
5929+ ...cfg ,
5930+ memory : {
5931+ backend : "qmd" ,
5932+ qmd : {
5933+ includeDefaultMemory : false ,
5934+ update : { interval : "0s" , debounceMs : 60_000 , onBoot : false } ,
5935+ paths : [ { path : newWorkspaceDir , pattern : "**/*.md" , name : "workspace" } ] ,
5936+ } ,
5937+ } ,
5938+ } as OpenClawConfig ;
5939+
5940+ const collectionName = `workspace-${ agentId } ` ;
5941+
5942+ spawnMock . mockImplementation ( ( _cmd : string , args : string [ ] ) => {
5943+ if ( args [ 0 ] === "collection" && args [ 1 ] === "list" ) {
5944+ const child = createMockChild ( { autoClose : false } ) ;
5945+ emitAndClose ( child , "stdout" , JSON . stringify ( [ collectionName ] ) ) ;
5946+ return child ;
5947+ }
5948+ if ( args [ 0 ] === "collection" && args [ 1 ] === "show" && args [ 2 ] === collectionName ) {
5949+ const child = createMockChild ( { autoClose : false } ) ;
5950+ emitAndClose (
5951+ child ,
5952+ "stdout" ,
5953+ [
5954+ `Collection: ${ collectionName } ` ,
5955+ ` Path: ${ containerRoot } ` ,
5956+ ` Pattern: **/*.md` ,
5957+ ` Include: yes (default)` ,
5958+ ] . join ( "\n" ) ,
5959+ ) ;
5960+ return child ;
5961+ }
5962+ return createMockChild ( ) ;
5963+ } ) ;
5964+
5965+ const { manager } = await createManager ( { mode : "full" } ) ;
5966+ await manager . close ( ) ;
5967+
5968+ const commands = spawnMock . mock . calls . map ( ( call : unknown [ ] ) => call [ 1 ] as string [ ] ) ;
5969+ const removeCall = commands . find (
5970+ ( args ) => args [ 0 ] === "collection" && args [ 1 ] === "remove" && args [ 2 ] === collectionName ,
5971+ ) ;
5972+ expect ( removeCall ) . toBeDefined ( ) ;
5973+ const addCall = commands . find ( ( args ) => {
5974+ if ( args [ 0 ] !== "collection" || args [ 1 ] !== "add" ) {
5975+ return false ;
5976+ }
5977+ const nameIdx = args . indexOf ( "--name" ) ;
5978+ return nameIdx >= 0 && args [ nameIdx + 1 ] === collectionName ;
5979+ } ) ;
5980+ expect ( addCall ) . toBeDefined ( ) ;
5981+ // Re-added at the host workspace root, not the stale container path.
5982+ expect ( addCall ?. [ 2 ] ) . toBe ( newWorkspaceDir ) ;
5983+ } ) ;
5984+
5985+ it ( "parseShownCollection extracts path and pattern from qmd collection show output" , async ( ) => {
5986+ // Unit test for the private parser — accessed via type cast to avoid exporting internals.
5987+ const { manager } = await createManager ( { mode : "status" } ) ;
5988+ type WithParser = {
5989+ parseShownCollection : ( output : string ) => { path ?: string ; pattern ?: string } ;
5990+ } ;
5991+ const parser = ( manager as unknown as WithParser ) . parseShownCollection . bind ( manager ) ;
5992+
5993+ const sampleOutput = [
5994+ "Collection: memory-dir-example" ,
5995+ " Path: /home/node/.openclaw/teams/example-team/workspace-example/memory" ,
5996+ " Pattern: **/*.md" ,
5997+ " Include: yes (default)" ,
5998+ ] . join ( "\n" ) ;
5999+
6000+ const result = parser ( sampleOutput ) ;
6001+ expect ( result . path ) . toBe ( "/home/node/.openclaw/teams/example-team/workspace-example/memory" ) ;
6002+ expect ( result . pattern ) . toBe ( "**/*.md" ) ;
6003+
6004+ // Tolerant of missing fields.
6005+ expect ( parser ( "" ) ) . toEqual ( { } ) ;
6006+ expect ( parser ( "Collection: no-path-here\n Include: yes" ) ) . toEqual ( { } ) ;
6007+
6008+ // Path-only (no pattern line).
6009+ const pathOnly = parser ( "Collection: x\n Path: /some/path\n" ) ;
6010+ expect ( pathOnly . path ) . toBe ( "/some/path" ) ;
6011+ expect ( pathOnly . pattern ) . toBeUndefined ( ) ;
6012+
6013+ await manager . close ( ) ;
6014+ } ) ;
58516015} ) ;
58526016
58536017function createDeferred < T > ( ) {
0 commit comments